diff --git a/beets/importer/session.py b/beets/importer/session.py index 46277837e8..aaf226e5d2 100644 --- a/beets/importer/session.py +++ b/beets/importer/session.py @@ -15,7 +15,7 @@ import os import time -from typing import TYPE_CHECKING, Sequence +from typing import TYPE_CHECKING from beets import config, dbcore, library, logging, plugins, util from beets.importer.tasks import Action @@ -25,6 +25,10 @@ from .state import ImportState if TYPE_CHECKING: + from typing import Literal, Sequence + + from beets.autotag import AlbumMatch, TrackMatch + from beets.importer.tasks import SingletonImportTask from beets.util import PathBytes from .tasks import ImportTask @@ -60,7 +64,7 @@ def __init__( lib: library.Library, loghandler: logging.Handler | None, paths: Sequence[PathBytes] | None, - query: dbcore.Query | None, + query: dbcore.Query | str | list[str] | tuple[str] | None, ): """Create a session. @@ -173,16 +177,20 @@ def log_choice(self, task: ImportTask, duplicate=False): elif task.choice_flag is Action.SKIP: self.tag_log("skip", paths) - def should_resume(self, path: PathBytes): + def should_resume(self, path: PathBytes) -> bool: raise NotImplementedError - def choose_match(self, task: ImportTask): + def choose_match( + self, task: ImportTask + ) -> Literal[Action.ASIS, Action.SKIP] | AlbumMatch | TrackMatch: raise NotImplementedError - def resolve_duplicate(self, task: ImportTask, found_duplicates): + def resolve_duplicate(self, task: ImportTask, found_duplicates) -> None: raise NotImplementedError - def choose_item(self, task: ImportTask): + def choose_item( + self, task: SingletonImportTask + ) -> Literal[Action.ASIS, Action.SKIP] | AlbumMatch | TrackMatch: raise NotImplementedError def run(self): diff --git a/beets/test/_common.py b/beets/test/_common.py index ffb2bfd65d..487f7c4420 100644 --- a/beets/test/_common.py +++ b/beets/test/_common.py @@ -107,7 +107,11 @@ def item(lib=None, **kwargs): # Dummy import session. def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False): - cls = commands.TerminalImportSession if cli else importer.ImportSession + cls = ( + commands.import_.session.TerminalImportSession + if cli + else importer.ImportSession + ) return cls(lib, loghandler, paths, query) diff --git a/beets/test/helper.py b/beets/test/helper.py index ea08ec840b..3cb1e4c3ca 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -54,7 +54,7 @@ from beets.importer import ImportSession from beets.library import Item, Library from beets.test import _common -from beets.ui.commands import TerminalImportSession +from beets.ui.commands.import_.session import TerminalImportSession from beets.util import ( MoveOperation, bytestring_path, diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 60e2014485..2e12cfa639 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -22,25 +22,41 @@ import errno import optparse import os.path -import re import sqlite3 -import struct import sys -import textwrap import traceback -import warnings -from difflib import SequenceMatcher -from functools import cache -from itertools import chain -from typing import Any, Callable, Literal +from typing import TYPE_CHECKING import confuse -from beets import config, library, logging, plugins, util +from beets import IncludeLazyConfig, 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.functemplate import template +from beets.ui import commands, core +from beets.ui._common import UserError +from beets.ui.colors import color_split, colorize, uncolorize +from beets.ui.core import ( + CommonOptionsParser, + Subcommand, + SubcommandsOptionParser, + _in_encoding, + _out_encoding, + get_path_formats, + get_replacements, + input_, + input_options, + input_select_objects, + input_yn, + print_, + should_move, + should_write, + show_model_changes, + split_into_lines, +) + +if TYPE_CHECKING: + from beets.library.library import Library + # On Windows platforms, use colorama to support "ANSI" terminal colors. if sys.platform == "win32": @@ -51,1458 +67,35 @@ else: colorama.init() +__all__: list[str] = [ + "CommonOptionsParser", + "Subcommand", + "SubcommandsOptionParser", + "_in_encoding", + "_out_encoding", + "UserError", + "color_split", + "colorize", + "commands", + "core", + "input_", + "input_options", + "input_select_objects", + "print_", + "should_write", + "should_move", + "show_model_changes", + "split_into_lines", + "uncolorize", +] + -log = logging.getLogger("beets") +log: logging.BeetsLogger = logging.getLogger("beets") if not log.handlers: log.addHandler(logging.StreamHandler()) log.propagate = False # Don't propagate to root handler. -PF_KEY_QUERIES = { - "comp": "comp:true", - "singleton": "singleton:true", -} - - -class UserError(Exception): - """UI exception. Commands should throw this in order to display - nonrecoverable errors to the user. - """ - - -# Encoding utilities. - - -def _in_encoding(): - """Get the encoding to use for *inputting* strings from the console.""" - return _stream_encoding(sys.stdin) - - -def _out_encoding(): - """Get the encoding to use for *outputting* strings to the console.""" - return _stream_encoding(sys.stdout) - - -def _stream_encoding(stream, default="utf-8"): - """A helper for `_in_encoding` and `_out_encoding`: get the stream's - preferred encoding, using a configured override or a default - fallback if neither is not specified. - """ - # Configured override? - encoding = config["terminal_encoding"].get() - if encoding: - return encoding - - # For testing: When sys.stdout or sys.stdin is a StringIO under the - # test harness, it doesn't have an `encoding` attribute. Just use - # UTF-8. - if not hasattr(stream, "encoding"): - return default - - # Python's guessed output stream encoding, or UTF-8 as a fallback - # (e.g., when piped to a file). - return stream.encoding or default - - -def decargs(arglist): - """Given a list of command-line argument bytestrings, attempts to - decode them to Unicode strings when running under Python 2. - - .. 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, - ) - return arglist - - -def print_(*strings: str, end: str = "\n") -> None: - """Like print, but rather than raising an error when a character - is not in the terminal's encoding's character set, just silently - replaces it. - - The `end` keyword argument behaves similarly to the built-in `print` - (it defaults to a newline). - """ - txt = f"{' '.join(strings or ('',))}{end}" - - # Encode the string and write it to stdout. - # On Python 3, sys.stdout expects text strings and uses the - # exception-throwing encoding error policy. To avoid throwing - # errors and use our configurable encoding override, we use the - # underlying bytes buffer instead. - if hasattr(sys.stdout, "buffer"): - out = txt.encode(_out_encoding(), "replace") - sys.stdout.buffer.write(out) - sys.stdout.buffer.flush() - else: - # In our test harnesses (e.g., DummyOut), sys.stdout.buffer - # does not exist. We instead just record the text string. - sys.stdout.write(txt) - - -# Configuration wrappers. - - -def _bool_fallback(a, b): - """Given a boolean or None, return the original value or a fallback.""" - if a is None: - assert isinstance(b, bool) - return b - else: - assert isinstance(a, bool) - return a - - -def should_write(write_opt=None): - """Decide whether a command that updates metadata should also write - tags, using the importer configuration as the default. - """ - return _bool_fallback(write_opt, config["import"]["write"].get(bool)) - - -def should_move(move_opt=None): - """Decide whether a command that updates metadata should also move - files when they're inside the library, using the importer - configuration as the default. - - Specifically, commands should move files after metadata updates only - when the importer is configured *either* to move *or* to copy files. - They should avoid moving files when the importer is configured not - to touch any filenames. - """ - return _bool_fallback( - move_opt, - config["import"]["move"].get(bool) - or config["import"]["copy"].get(bool), - ) - - -# Input prompts. - - -def indent(count): - """Returns a string with `count` many spaces.""" - return " " * count - - -def input_(prompt=None): - """Like `input`, but decodes the result to a Unicode string. - Raises a UserError if stdin is not available. The prompt is sent to - stdout rather than stderr. A printed between the prompt and the - input cursor. - """ - # raw_input incorrectly sends prompts to stderr, not stdout, so we - # use print_() explicitly to display prompts. - # https://bugs.python.org/issue1927 - if prompt: - print_(prompt, end=" ") - - try: - resp = input() - except EOFError: - raise UserError("stdin stream ended while input required") - - return resp - - -def input_options( - options, - require=False, - prompt=None, - fallback_prompt=None, - numrange=None, - default=None, - max_width=72, -): - """Prompts a user for input. The sequence of `options` defines the - choices the user has. A single-letter shortcut is inferred for each - option; the user's choice is returned as that single, lower-case - letter. The options should be provided as lower-case strings unless - a particular shortcut is desired; in that case, only that letter - should be capitalized. - - By default, the first option is the default. `default` can be provided to - override this. If `require` is provided, then there is no default. The - prompt and fallback prompt are also inferred but can be overridden. - - If numrange is provided, it is a pair of `(high, low)` (both ints) - indicating that, in addition to `options`, the user may enter an - integer in that inclusive range. - - `max_width` specifies the maximum number of columns in the - automatically generated prompt string. - """ - # Assign single letters to each option. Also capitalize the options - # to indicate the letter. - letters = {} - display_letters = [] - capitalized = [] - first = True - for option in options: - # Is a letter already capitalized? - for letter in option: - if letter.isalpha() and letter.upper() == letter: - found_letter = letter - break - else: - # Infer a letter. - for letter in option: - if not letter.isalpha(): - continue # Don't use punctuation. - if letter not in letters: - found_letter = letter - break - else: - raise ValueError("no unambiguous lettering found") - - letters[found_letter.lower()] = option - index = option.index(found_letter) - - # Mark the option's shortcut letter for display. - if not require and ( - (default is None and not numrange and first) - or ( - isinstance(default, str) - and found_letter.lower() == default.lower() - ) - ): - # The first option is the default; mark it. - show_letter = f"[{found_letter.upper()}]" - is_default = True - else: - show_letter = found_letter.upper() - is_default = False - - # Colorize the letter shortcut. - show_letter = colorize( - "action_default" if is_default else "action", show_letter - ) - - # Insert the highlighted letter back into the word. - descr_color = "action_default" if is_default else "action_description" - capitalized.append( - colorize(descr_color, option[:index]) - + show_letter - + colorize(descr_color, option[index + 1 :]) - ) - display_letters.append(found_letter.upper()) - - first = False - - # The default is just the first option if unspecified. - if require: - default = None - elif default is None: - if numrange: - default = numrange[0] - else: - default = display_letters[0].lower() - - # Make a prompt if one is not provided. - if not prompt: - prompt_parts = [] - prompt_part_lengths = [] - if numrange: - if isinstance(default, int): - default_name = str(default) - default_name = colorize("action_default", default_name) - tmpl = "# selection (default {})" - prompt_parts.append(tmpl.format(default_name)) - prompt_part_lengths.append(len(tmpl) - 2 + len(str(default))) - else: - prompt_parts.append("# selection") - prompt_part_lengths.append(len(prompt_parts[-1])) - prompt_parts += capitalized - prompt_part_lengths += [len(s) for s in options] - - # Wrap the query text. - # Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow - prompt = colorize("action", "\u279c ") - line_length = 0 - for i, (part, length) in enumerate( - zip(prompt_parts, prompt_part_lengths) - ): - # Add punctuation. - if i == len(prompt_parts) - 1: - part += colorize("action_description", "?") - else: - part += colorize("action_description", ",") - length += 1 - - # Choose either the current line or the beginning of the next. - if line_length + length + 1 > max_width: - prompt += "\n" - line_length = 0 - - if line_length != 0: - # Not the beginning of the line; need a space. - part = f" {part}" - length += 1 - - prompt += part - line_length += length - - # Make a fallback prompt too. This is displayed if the user enters - # something that is not recognized. - if not fallback_prompt: - fallback_prompt = "Enter one of " - if numrange: - fallback_prompt += "{}-{}, ".format(*numrange) - fallback_prompt += f"{', '.join(display_letters)}:" - - resp = input_(prompt) - while True: - resp = resp.strip().lower() - - # Try default option. - if default is not None and not resp: - resp = default - - # Try an integer input if available. - if numrange: - try: - resp = int(resp) - except ValueError: - pass - else: - low, high = numrange - if low <= resp <= high: - return resp - else: - resp = None - - # Try a normal letter input. - if resp: - resp = resp[0] - if resp in letters: - return resp - - # Prompt for new input. - resp = input_(fallback_prompt) - - -def input_yn(prompt, require=False): - """Prompts the user for a "yes" or "no" response. The default is - "yes" unless `require` is `True`, in which case there is no default. - """ - # Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow - yesno = colorize("action", "\u279c ") + colorize( - "action_description", "Enter Y or N:" - ) - sel = input_options(("y", "n"), require, prompt, yesno) - return sel == "y" - - -def input_select_objects(prompt, objs, rep, prompt_all=None): - """Prompt to user to choose all, none, or some of the given objects. - Return the list of selected objects. - - `prompt` is the prompt string to use for each question (it should be - phrased as an imperative verb). If `prompt_all` is given, it is used - instead of `prompt` for the first (yes(/no/select) question. - `rep` is a function to call on each object to print it out when confirming - objects individually. - """ - choice = input_options( - ("y", "n", "s"), False, f"{prompt_all or prompt}? (Yes/no/select)" - ) - print() # Blank line. - - if choice == "y": # Yes. - return objs - - elif choice == "s": # Select. - out = [] - for obj in objs: - rep(obj) - answer = input_options( - ("y", "n", "q"), - True, - f"{prompt}? (yes/no/quit)", - "Enter Y or N:", - ) - if answer == "y": - out.append(obj) - elif answer == "q": - return out - return out - - else: # No. - return [] - - -# Colorization. - -# ANSI terminal colorization code heavily inspired by pygments: -# https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py -# (pygments is by Tim Hatch, Armin Ronacher, et al.) -COLOR_ESCAPE = "\x1b" -LEGACY_COLORS = { - "black": ["black"], - "darkred": ["red"], - "darkgreen": ["green"], - "brown": ["yellow"], - "darkyellow": ["yellow"], - "darkblue": ["blue"], - "purple": ["magenta"], - "darkmagenta": ["magenta"], - "teal": ["cyan"], - "darkcyan": ["cyan"], - "lightgray": ["white"], - "darkgray": ["bold", "black"], - "red": ["bold", "red"], - "green": ["bold", "green"], - "yellow": ["bold", "yellow"], - "blue": ["bold", "blue"], - "fuchsia": ["bold", "magenta"], - "magenta": ["bold", "magenta"], - "turquoise": ["bold", "cyan"], - "cyan": ["bold", "cyan"], - "white": ["bold", "white"], -} -# All ANSI Colors. -CODE_BY_COLOR = { - # Styles. - "normal": 0, - "bold": 1, - "faint": 2, - # "italic": 3, - "underline": 4, - # "blink_slow": 5, - # "blink_rapid": 6, - "inverse": 7, - # "conceal": 8, - # "crossed_out": 9 - # Text colors. - "black": 30, - "red": 31, - "green": 32, - "yellow": 33, - "blue": 34, - "magenta": 35, - "cyan": 36, - "white": 37, - # Background colors. - "bg_black": 40, - "bg_red": 41, - "bg_green": 42, - "bg_yellow": 43, - "bg_blue": 44, - "bg_magenta": 45, - "bg_cyan": 46, - "bg_white": 47, -} -RESET_COLOR = f"{COLOR_ESCAPE}[39;49;00m" -# Precompile common ANSI-escape regex patterns -ANSI_CODE_REGEX = re.compile(rf"({COLOR_ESCAPE}\[[;0-9]*m)") -ESC_TEXT_REGEX = re.compile( - rf"""(?P[^{COLOR_ESCAPE}]*) - (?P(?:{ANSI_CODE_REGEX.pattern})+) - (?P[^{COLOR_ESCAPE}]+)(?P{re.escape(RESET_COLOR)}) - (?P[^{COLOR_ESCAPE}]*)""", - re.VERBOSE, -) -ColorName = Literal[ - "text_success", - "text_warning", - "text_error", - "text_highlight", - "text_highlight_minor", - "action_default", - "action", - # New Colors - "text_faint", - "import_path", - "import_path_items", - "action_description", - "changed", - "text_diff_added", - "text_diff_removed", -] - - -@cache -def get_color_config() -> dict[ColorName, str]: - """Parse and validate color configuration, converting names to ANSI codes. - - Processes the UI color configuration, handling both new list format and - legacy single-color format. Validates all color names against known codes - and raises an error for any invalid entries. - """ - colors_by_color_name: dict[ColorName, list[str]] = { - k: (v if isinstance(v, list) else LEGACY_COLORS.get(v, [v])) - for k, v in config["ui"]["colors"].flatten().items() - } - - if invalid_colors := ( - set(chain.from_iterable(colors_by_color_name.values())) - - CODE_BY_COLOR.keys() - ): - raise UserError( - f"Invalid color(s) in configuration: {', '.join(invalid_colors)}" - ) - - return { - n: ";".join(str(CODE_BY_COLOR[c]) for c in colors) - for n, colors in colors_by_color_name.items() - } - - -def colorize(color_name: ColorName, text: str) -> str: - """Apply ANSI color formatting to text based on configuration settings. - - Returns colored text when color output is enabled and NO_COLOR environment - variable is not set, otherwise returns plain text unchanged. - """ - if config["ui"]["color"] and "NO_COLOR" not in os.environ: - color_code = get_color_config()[color_name] - return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}" - - return text - - -def uncolorize(colored_text): - """Remove colors from a string.""" - # Define a regular expression to match ANSI codes. - # See: http://stackoverflow.com/a/2187024/1382707 - # Explanation of regular expression: - # \x1b - matches ESC character - # \[ - matches opening square bracket - # [;\d]* - matches a sequence consisting of one or more digits or - # semicola - # [A-Za-z] - matches a letter - return ANSI_CODE_REGEX.sub("", colored_text) - - -def color_split(colored_text, index): - length = 0 - pre_split = "" - post_split = "" - found_color_code = None - found_split = False - for part in ANSI_CODE_REGEX.split(colored_text): - # Count how many real letters we have passed - length += color_len(part) - if found_split: - post_split += part - else: - if ANSI_CODE_REGEX.match(part): - # This is a color code - if part == RESET_COLOR: - found_color_code = None - else: - found_color_code = part - pre_split += part - else: - if index < length: - # Found part with our split in. - split_index = index - (length - color_len(part)) - found_split = True - if found_color_code: - pre_split += f"{part[:split_index]}{RESET_COLOR}" - post_split += f"{found_color_code}{part[split_index:]}" - else: - pre_split += part[:split_index] - post_split += part[split_index:] - else: - # Not found, add this part to the pre split - pre_split += part - return pre_split, post_split - - -def color_len(colored_text): - """Measure the length of a string while excluding ANSI codes from the - measurement. The standard `len(my_string)` method also counts ANSI codes - to the string length, which is counterproductive when layouting a - Terminal interface. - """ - # Return the length of the uncolored string. - return len(uncolorize(colored_text)) - - -def _colordiff(a: Any, b: Any) -> tuple[str, str]: - """Given two values, return the same pair of strings except with - their differences highlighted in the specified color. Strings are - highlighted intelligently to show differences; other values are - stringified and highlighted in their entirety. - """ - # First, convert paths to readable format - if isinstance(a, bytes) or isinstance(b, bytes): - # A path field. - a = util.displayable_path(a) - b = util.displayable_path(b) - - if not isinstance(a, str) or not isinstance(b, str): - # Non-strings: use ordinary equality. - if a == b: - return str(a), str(b) - else: - return ( - colorize("text_diff_removed", str(a)), - colorize("text_diff_added", str(b)), - ) - - before = "" - after = "" - - matcher = SequenceMatcher(lambda x: False, a, b) - for op, a_start, a_end, b_start, b_end in matcher.get_opcodes(): - before_part, after_part = a[a_start:a_end], b[b_start:b_end] - if op in {"delete", "replace"}: - before_part = colorize("text_diff_removed", before_part) - if op in {"insert", "replace"}: - after_part = colorize("text_diff_added", after_part) - - before += before_part - after += after_part - - return before, after - - -def colordiff(a, b): - """Colorize differences between two values if color is enabled. - (Like _colordiff but conditional.) - """ - if config["ui"]["color"]: - return _colordiff(a, b) - else: - return str(a), str(b) - - -def get_path_formats(subview=None): - """Get the configuration's path formats as a list of query/template - pairs. - """ - path_formats = [] - subview = subview or config["paths"] - for query, view in subview.items(): - query = PF_KEY_QUERIES.get(query, query) # Expand common queries. - path_formats.append((query, template(view.as_str()))) - return path_formats - - -def get_replacements(): - """Confuse validation function that reads regex/string pairs.""" - replacements = [] - for pattern, repl in config["replace"].get(dict).items(): - repl = repl or "" - try: - replacements.append((re.compile(pattern), repl)) - except re.error: - raise UserError( - f"malformed regular expression in replace: {pattern}" - ) - return replacements - - -def term_width(): - """Get the width (columns) of the terminal.""" - fallback = config["ui"]["terminal_width"].get(int) - - # The fcntl and termios modules are not available on non-Unix - # platforms, so we fall back to a constant. - try: - import fcntl - import termios - except ImportError: - return fallback - - try: - buf = fcntl.ioctl(0, termios.TIOCGWINSZ, " " * 4) - except OSError: - return fallback - try: - height, width = struct.unpack("hh", buf) - except struct.error: - return fallback - return width - - -def split_into_lines(string, width_tuple): - """Splits string into a list of substrings at whitespace. - - `width_tuple` is a 3-tuple of `(first_width, last_width, middle_width)`. - The first substring has a length not longer than `first_width`, the last - substring has a length not longer than `last_width`, and all other - substrings have a length not longer than `middle_width`. - `string` may contain ANSI codes at word borders. - """ - first_width, middle_width, last_width = width_tuple - words = [] - - if uncolorize(string) == string: - # No colors in string - words = string.split() - else: - # Use a regex to find escapes and the text within them. - for m in ESC_TEXT_REGEX.finditer(string): - # m contains four groups: - # pretext - any text before escape sequence - # esc - intitial escape sequence - # text - text, no escape sequence, may contain spaces - # reset - ASCII colour reset - space_before_text = False - if m.group("pretext") != "": - # Some pretext found, let's handle it - # Add any words in the pretext - words += m.group("pretext").split() - if m.group("pretext")[-1] == " ": - # Pretext ended on a space - space_before_text = True - else: - # Pretext ended mid-word, ensure next word - pass - else: - # pretext empty, treat as if there is a space before - space_before_text = True - if m.group("text")[0] == " ": - # First character of the text is a space - space_before_text = True - # Now, handle the words in the main text: - raw_words = m.group("text").split() - if space_before_text: - # Colorize each word with pre/post escapes - # Reconstruct colored words - words += [ - f"{m['esc']}{raw_word}{RESET_COLOR}" - for raw_word in raw_words - ] - elif raw_words: - # Pretext stops mid-word - if m.group("esc") != RESET_COLOR: - # Add the rest of the current word, with a reset after it - words[-1] += f"{m['esc']}{raw_words[0]}{RESET_COLOR}" - # Add the subsequent colored words: - words += [ - f"{m['esc']}{raw_word}{RESET_COLOR}" - for raw_word in raw_words[1:] - ] - else: - # Caught a mid-word escape sequence - words[-1] += raw_words[0] - words += raw_words[1:] - if ( - m.group("text")[-1] != " " - and m.group("posttext") != "" - and m.group("posttext")[0] != " " - ): - # reset falls mid-word - post_text = m.group("posttext").split() - words[-1] += post_text[0] - words += post_text[1:] - else: - # Add any words after escape sequence - words += m.group("posttext").split() - result = [] - next_substr = "" - # Iterate over all words. - previous_fit = False - for i in range(len(words)): - if i == 0: - pot_substr = words[i] - else: - # (optimistically) add the next word to check the fit - pot_substr = " ".join([next_substr, words[i]]) - # Find out if the pot(ential)_substr fits into the next substring. - fits_first = len(result) == 0 and color_len(pot_substr) <= first_width - fits_middle = len(result) != 0 and color_len(pot_substr) <= middle_width - if fits_first or fits_middle: - # Fitted(!) let's try and add another word before appending - next_substr = pot_substr - previous_fit = True - elif not fits_first and not fits_middle and previous_fit: - # Extra word didn't fit, append what we have - result.append(next_substr) - next_substr = words[i] - previous_fit = color_len(next_substr) <= middle_width - else: - # Didn't fit anywhere - if uncolorize(pot_substr) == pot_substr: - # Simple uncolored string, append a cropped word - if len(result) == 0: - # Crop word by the first_width for the first line - result.append(pot_substr[:first_width]) - # add rest of word to next line - next_substr = pot_substr[first_width:] - else: - result.append(pot_substr[:middle_width]) - next_substr = pot_substr[middle_width:] - else: - # Colored strings - if len(result) == 0: - this_line, next_line = color_split(pot_substr, first_width) - result.append(this_line) - next_substr = next_line - else: - this_line, next_line = color_split(pot_substr, middle_width) - result.append(this_line) - next_substr = next_line - previous_fit = color_len(next_substr) <= middle_width - - # We finished constructing the substrings, but the last substring - # has not yet been added to the result. - result.append(next_substr) - # Also, the length of the last substring was only checked against - # `middle_width`. Append an empty substring as the new last substring if - # the last substring is too long. - if not color_len(next_substr) <= last_width: - result.append("") - return result - - -def print_column_layout( - indent_str, left, right, separator=" -> ", max_width=term_width() -): - """Print left & right data, with separator inbetween - 'left' and 'right' have a structure of: - {'prefix':u'','contents':u'','suffix':u'','width':0} - In a column layout the printing will be: - {indent_str}{lhs0}{separator}{rhs0} - {lhs1 / padding }{rhs1} - ... - The first line of each column (i.e. {lhs0} or {rhs0}) is: - {prefix}{part of contents}{suffix} - With subsequent lines (i.e. {lhs1}, {rhs1} onwards) being the - rest of contents, wrapped if the width would be otherwise exceeded. - """ - if f"{right['prefix']}{right['contents']}{right['suffix']}" == "": - # No right hand information, so we don't need a separator. - separator = "" - first_line_no_wrap = ( - f"{indent_str}{left['prefix']}{left['contents']}{left['suffix']}" - f"{separator}{right['prefix']}{right['contents']}{right['suffix']}" - ) - if color_len(first_line_no_wrap) < max_width: - # Everything fits, print out line. - print_(first_line_no_wrap) - else: - # Wrap into columns - if "width" not in left or "width" not in right: - # If widths have not been defined, set to share space. - left["width"] = ( - max_width - len(indent_str) - color_len(separator) - ) // 2 - right["width"] = ( - max_width - len(indent_str) - color_len(separator) - ) // 2 - # On the first line, account for suffix as well as prefix - left_width_tuple = ( - left["width"] - - color_len(left["prefix"]) - - color_len(left["suffix"]), - left["width"] - color_len(left["prefix"]), - left["width"] - color_len(left["prefix"]), - ) - - left_split = split_into_lines(left["contents"], left_width_tuple) - right_width_tuple = ( - right["width"] - - color_len(right["prefix"]) - - color_len(right["suffix"]), - right["width"] - color_len(right["prefix"]), - right["width"] - color_len(right["prefix"]), - ) - - right_split = split_into_lines(right["contents"], right_width_tuple) - max_line_count = max(len(left_split), len(right_split)) - - out = "" - for i in range(max_line_count): - # indentation - out += indent_str - - # Prefix or indent_str for line - if i == 0: - out += left["prefix"] - else: - out += indent(color_len(left["prefix"])) - - # Line i of left hand side contents. - if i < len(left_split): - out += left_split[i] - left_part_len = color_len(left_split[i]) - else: - left_part_len = 0 - - # Padding until end of column. - # Note: differs from original - # column calcs in not -1 afterwards for space - # in track number as that is included in 'prefix' - padding = left["width"] - color_len(left["prefix"]) - left_part_len - - # Remove some padding on the first line to display - # length - if i == 0: - padding -= color_len(left["suffix"]) - - out += indent(padding) - - if i == 0: - out += left["suffix"] - - # Separator between columns. - if i == 0: - out += separator - else: - out += indent(color_len(separator)) - - # Right prefix, contents, padding, suffix - if i == 0: - out += right["prefix"] - else: - out += indent(color_len(right["prefix"])) - - # Line i of right hand side. - if i < len(right_split): - out += right_split[i] - right_part_len = color_len(right_split[i]) - else: - right_part_len = 0 - - # Padding until end of column - padding = ( - right["width"] - color_len(right["prefix"]) - right_part_len - ) - # Remove some padding on the first line to display - # length - if i == 0: - padding -= color_len(right["suffix"]) - out += indent(padding) - # Length in first line - if i == 0: - out += right["suffix"] - - # Linebreak, except in the last line. - if i < max_line_count - 1: - out += "\n" - - # Constructed all of the columns, now print - print_(out) - - -def print_newline_layout( - indent_str, left, right, separator=" -> ", max_width=term_width() -): - """Prints using a newline separator between left & right if - they go over their allocated widths. The datastructures are - shared with the column layout. In contrast to the column layout, - the prefix and suffix are printed at the beginning and end of - the contents. If no wrapping is required (i.e. everything fits) the - first line will look exactly the same as the column layout: - {indent}{lhs0}{separator}{rhs0} - However if this would go over the width given, the layout now becomes: - {indent}{lhs0} - {indent}{separator}{rhs0} - If {lhs0} would go over the maximum width, the subsequent lines are - indented a second time for ease of reading. - """ - if f"{right['prefix']}{right['contents']}{right['suffix']}" == "": - # No right hand information, so we don't need a separator. - separator = "" - first_line_no_wrap = ( - f"{indent_str}{left['prefix']}{left['contents']}{left['suffix']}" - f"{separator}{right['prefix']}{right['contents']}{right['suffix']}" - ) - if color_len(first_line_no_wrap) < max_width: - # Everything fits, print out line. - print_(first_line_no_wrap) - else: - # Newline separation, with wrapping - empty_space = max_width - len(indent_str) - # On lower lines we will double the indent for clarity - left_width_tuple = ( - empty_space, - empty_space - len(indent_str), - empty_space - len(indent_str), - ) - left_str = f"{left['prefix']}{left['contents']}{left['suffix']}" - left_split = split_into_lines(left_str, left_width_tuple) - # Repeat calculations for rhs, including separator on first line - right_width_tuple = ( - empty_space - color_len(separator), - empty_space - len(indent_str), - empty_space - len(indent_str), - ) - right_str = f"{right['prefix']}{right['contents']}{right['suffix']}" - right_split = split_into_lines(right_str, right_width_tuple) - for i, line in enumerate(left_split): - if i == 0: - print_(f"{indent_str}{line}") - elif line != "": - # Ignore empty lines - print_(f"{indent_str * 2}{line}") - for i, line in enumerate(right_split): - if i == 0: - print_(f"{indent_str}{separator}{line}") - elif line != "": - print_(f"{indent_str * 2}{line}") - - -FLOAT_EPSILON = 0.01 - - -def _field_diff(field, old, old_fmt, new, new_fmt): - """Given two Model objects and their formatted views, format their values - for `field` and highlight changes among them. Return a human-readable - string. If the value has not changed, return None instead. - """ - oldval = old.get(field) - newval = new.get(field) - - # If no change, abort. - if ( - isinstance(oldval, float) - and isinstance(newval, float) - and abs(oldval - newval) < FLOAT_EPSILON - ): - return None - elif oldval == newval: - return None - - # Get formatted values for output. - oldstr = old_fmt.get(field, "") - newstr = new_fmt.get(field, "") - - # For strings, highlight changes. For others, colorize the whole - # thing. - if isinstance(oldval, str): - oldstr, newstr = colordiff(oldval, newstr) - else: - oldstr = colorize("text_diff_removed", oldstr) - newstr = colorize("text_diff_added", newstr) - - return f"{oldstr} -> {newstr}" - - -def show_model_changes( - new, old=None, fields=None, always=False, print_obj: bool = True -): - """Given a Model object, print a list of changes from its pristine - version stored in the database. Return a boolean indicating whether - any changes were found. - - `old` may be the "original" object to avoid using the pristine - version from the database. `fields` may be a list of fields to - restrict the detection to. `always` indicates whether the object is - always identified, regardless of whether any changes are present. - """ - old = old or new._db._get(type(new), new.id) - - # Keep the formatted views around instead of re-creating them in each - # iteration step - old_fmt = old.formatted() - new_fmt = new.formatted() - - # Build up lines showing changed fields. - changes = [] - for field in old: - # Subset of the fields. Never show mtime. - if field == "mtime" or (fields and field not in fields): - continue - - # Detect and show difference for this field. - line = _field_diff(field, old, old_fmt, new, new_fmt) - if line: - changes.append(f" {field}: {line}") - - # New fields. - for field in set(new) - set(old): - if fields and field not in fields: - continue - - changes.append( - f" {field}: {colorize('text_highlight', new_fmt[field])}" - ) - - # Print changes. - if print_obj and (changes or always): - print_(format(old)) - if changes: - print_("\n".join(changes)) - - return bool(changes) - - -def show_path_changes(path_changes): - """Given a list of tuples (source, destination) that indicate the - path changes, log the changes as INFO-level output to the beets log. - The output is guaranteed to be unicode. - - Every pair is shown on a single line if the terminal width permits it, - else it is split over two lines. E.g., - - Source -> Destination - - vs. - - Source - -> Destination - """ - sources, destinations = zip(*path_changes) - - # Ensure unicode output - sources = list(map(util.displayable_path, sources)) - destinations = list(map(util.displayable_path, destinations)) - - # Calculate widths for terminal split - col_width = (term_width() - len(" -> ")) // 2 - max_width = len(max(sources + destinations, key=len)) - - if max_width > col_width: - # Print every change over two lines - for source, dest in zip(sources, destinations): - color_source, color_dest = colordiff(source, dest) - print_(f"{color_source} \n -> {color_dest}") - else: - # Print every change on a single line, and add a header - title_pad = max_width - len("Source ") + len(" -> ") - - print_(f"Source {' ' * title_pad} Destination") - for source, dest in zip(sources, destinations): - pad = max_width - len(source) - color_source, color_dest = colordiff(source, dest) - print_(f"{color_source} {' ' * pad} -> {color_dest}") - - -# Helper functions for option parsing. - - -def _store_dict(option, opt_str, value, parser): - """Custom action callback to parse options which have ``key=value`` - pairs as values. All such pairs passed for this option are - aggregated into a dictionary. - """ - dest = option.dest - option_values = getattr(parser.values, dest, None) - - if option_values is None: - # This is the first supplied ``key=value`` pair of option. - # Initialize empty dictionary and get a reference to it. - setattr(parser.values, dest, {}) - option_values = getattr(parser.values, dest) - - try: - key, value = value.split("=", 1) - if not (key and value): - raise ValueError - except ValueError: - raise UserError( - f"supplied argument `{value}' is not of the form `key=value'" - ) - - option_values[key] = value - - -class CommonOptionsParser(optparse.OptionParser): - """Offers a simple way to add common formatting options. - - Options available include: - - matching albums instead of tracks: add_album_option() - - showing paths instead of items/albums: add_path_option() - - changing the format of displayed items/albums: add_format_option() - - The last one can have several behaviors: - - against a special target - - with a certain format - - autodetected target with the album option - - Each method is fully documented in the related method. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._album_flags = False - # this serves both as an indicator that we offer the feature AND allows - # us to check whether it has been specified on the CLI - bypassing the - # fact that arguments may be in any order - - def add_album_option(self, flags=("-a", "--album")): - """Add a -a/--album option to match albums instead of tracks. - - If used then the format option can auto-detect whether we're setting - the format for items or albums. - Sets the album property on the options extracted from the CLI. - """ - album = optparse.Option( - *flags, action="store_true", help="match albums instead of tracks" - ) - self.add_option(album) - self._album_flags = set(flags) - - def _set_format( - self, - option, - opt_str, - value, - parser, - target=None, - fmt=None, - store_true=False, - ): - """Internal callback that sets the correct format while parsing CLI - arguments. - """ - if store_true: - setattr(parser.values, option.dest, True) - - # Use the explicitly specified format, or the string from the option. - value = fmt or value or "" - parser.values.format = value - - if target: - config[target._format_config_key].set(value) - else: - if self._album_flags: - if parser.values.album: - target = library.Album - else: - # the option is either missing either not parsed yet - if self._album_flags & set(parser.rargs): - target = library.Album - else: - target = library.Item - config[target._format_config_key].set(value) - else: - config[library.Item._format_config_key].set(value) - config[library.Album._format_config_key].set(value) - - def add_path_option(self, flags=("-p", "--path")): - """Add a -p/--path option to display the path instead of the default - format. - - By default this affects both items and albums. If add_album_option() - is used then the target will be autodetected. - - Sets the format property to '$path' on the options extracted from the - CLI. - """ - path = optparse.Option( - *flags, - nargs=0, - action="callback", - callback=self._set_format, - callback_kwargs={"fmt": "$path", "store_true": True}, - help="print paths for matched items or albums", - ) - self.add_option(path) - - def add_format_option(self, flags=("-f", "--format"), target=None): - """Add -f/--format option to print some LibModel instances with a - custom format. - - `target` is optional and can be one of ``library.Item``, 'item', - ``library.Album`` and 'album'. - - Several behaviors are available: - - if `target` is given then the format is only applied to that - LibModel - - if the album option is used then the target will be autodetected - - otherwise the format is applied to both items and albums. - - Sets the format property on the options extracted from the CLI. - """ - kwargs = {} - if target: - if isinstance(target, str): - target = {"item": library.Item, "album": library.Album}[target] - kwargs["target"] = target - - opt = optparse.Option( - *flags, - action="callback", - callback=self._set_format, - callback_kwargs=kwargs, - help="print with custom format", - ) - self.add_option(opt) - - def add_all_common_options(self): - """Add album, path and format options.""" - self.add_album_option() - self.add_path_option() - self.add_format_option() - - -# Subcommand parsing infrastructure. -# -# This is a fairly generic subcommand parser for optparse. It is -# maintained externally here: -# https://gist.github.com/462717 -# There you will also find a better description of the code and a more -# succinct example program. - - -class Subcommand: - """A subcommand of a root command-line application that may be - invoked by a SubcommandOptionParser. - """ - - func: Callable[[library.Library, optparse.Values, list[str]], Any] - - def __init__(self, name, parser=None, help="", aliases=(), hide=False): - """Creates a new subcommand. name is the primary way to invoke - the subcommand; aliases are alternate names. parser is an - OptionParser responsible for parsing the subcommand's options. - help is a short description of the command. If no parser is - given, it defaults to a new, empty CommonOptionsParser. - """ - self.name = name - self.parser = parser or CommonOptionsParser() - self.aliases = aliases - self.help = help - self.hide = hide - self._root_parser = None - - def print_help(self): - self.parser.print_help() - - def parse_args(self, args): - return self.parser.parse_args(args) - - @property - def root_parser(self): - return self._root_parser - - @root_parser.setter - def root_parser(self, root_parser): - self._root_parser = root_parser - self.parser.prog = ( - f"{as_string(root_parser.get_prog_name())} {self.name}" - ) - - -class SubcommandsOptionParser(CommonOptionsParser): - """A variant of OptionParser that parses subcommands and their - arguments. - """ - - def __init__(self, *args, **kwargs): - """Create a new subcommand-aware option parser. All of the - options to OptionParser.__init__ are supported in addition - to subcommands, a sequence of Subcommand objects. - """ - # A more helpful default usage. - if "usage" not in kwargs: - kwargs["usage"] = """ - %prog COMMAND [ARGS...] - %prog help COMMAND""" - kwargs["add_help_option"] = False - - # Super constructor. - super().__init__(*args, **kwargs) - - # Our root parser needs to stop on the first unrecognized argument. - self.disable_interspersed_args() - - self.subcommands = [] - - def add_subcommand(self, *cmds): - """Adds a Subcommand object to the parser's list of commands.""" - for cmd in cmds: - cmd.root_parser = self - self.subcommands.append(cmd) - - # Add the list of subcommands to the help message. - def format_help(self, formatter=None): - # Get the original help message, to which we will append. - out = super().format_help(formatter) - if formatter is None: - formatter = self.formatter - - # Subcommands header. - result = ["\n"] - result.append(formatter.format_heading("Commands")) - formatter.indent() - - # Generate the display names (including aliases). - # Also determine the help position. - disp_names = [] - help_position = 0 - subcommands = [c for c in self.subcommands if not c.hide] - subcommands.sort(key=lambda c: c.name) - for subcommand in subcommands: - name = subcommand.name - if subcommand.aliases: - name += f" ({', '.join(subcommand.aliases)})" - disp_names.append(name) - - # Set the help position based on the max width. - proposed_help_position = len(name) + formatter.current_indent + 2 - if proposed_help_position <= formatter.max_help_position: - help_position = max(help_position, proposed_help_position) - - # Add each subcommand to the output. - for subcommand, name in zip(subcommands, disp_names): - # Lifted directly from optparse.py. - name_width = help_position - formatter.current_indent - 2 - if len(name) > name_width: - name = f"{' ' * formatter.current_indent}{name}\n" - indent_first = help_position - else: - name = f"{' ' * formatter.current_indent}{name:<{name_width}}\n" - indent_first = 0 - result.append(name) - help_width = formatter.width - help_position - help_lines = textwrap.wrap(subcommand.help, help_width) - help_line = help_lines[0] if help_lines else "" - result.append(f"{' ' * indent_first}{help_line}\n") - result.extend( - [f"{' ' * help_position}{line}\n" for line in help_lines[1:]] - ) - formatter.dedent() - - # Concatenate the original help message with the subcommand - # list. - return f"{out}{''.join(result)}" - - def _subcommand_for_name(self, name): - """Return the subcommand in self.subcommands matching the - given name. The name may either be the name of a subcommand or - an alias. If no subcommand matches, returns None. - """ - for subcommand in self.subcommands: - if name == subcommand.name or name in subcommand.aliases: - return subcommand - return None - - def parse_global_options(self, args): - """Parse options up to the subcommand argument. Returns a tuple - of the options object and the remaining arguments. - """ - options, subargs = self.parse_args(args) - - # Force the help command - if options.help: - subargs = ["help"] - elif options.version: - subargs = ["version"] - return options, subargs - - def parse_subcommand(self, args): - """Given the `args` left unused by a `parse_global_options`, - return the invoked subcommand, the subcommand options, and the - subcommand arguments. - """ - # Help is default command - if not args: - args = ["help"] - - cmdname = args.pop(0) - subcommand = self._subcommand_for_name(cmdname) - if not subcommand: - raise UserError(f"unknown command '{cmdname}'") - - suboptions, subargs = subcommand.parse_args(args) - return subcommand, suboptions, subargs - - -optparse.Option.ALWAYS_TYPED_ACTIONS += ("callback",) - - # The main entry point and bootstrapping. @@ -1513,24 +106,24 @@ def _setup( Returns a list of subcommands, a list of plugins, and a library instance. """ - config = _configure(options) + config: IncludeLazyConfig = _configure(options) plugins.load_plugins() # Get the default subcommands. from beets.ui.commands import default_commands - subcommands = list(default_commands) + subcommands: list[Subcommand] = list(default_commands) subcommands.extend(plugins.commands()) if lib is None: lib = _open_library(config) - plugins.send("library_opened", lib=lib) + _ = plugins.send("library_opened", lib=lib) return subcommands, lib -def _configure(options): +def _configure(options: optparse.Values) -> IncludeLazyConfig: """Amend the global configuration object with command line options.""" # Add any additional config files specified with --config. This # special handling lets specified plugins get loaded before we @@ -1605,36 +198,36 @@ def _open_library(config: confuse.LazyConfig) -> library.Library: return lib -def _raw_main(args: list[str], lib=None) -> None: +def _raw_main(args: list[str], lib: Library | None = None) -> None: """A helper function for `main` without top-level exception handling. """ parser = SubcommandsOptionParser() parser.add_format_option(flags=("--format-item",), target=library.Item) parser.add_format_option(flags=("--format-album",), target=library.Album) - parser.add_option( + _ = parser.add_option( "-l", "--library", dest="library", help="library database file to use" ) - parser.add_option( + _ = parser.add_option( "-d", "--directory", dest="directory", help="destination music directory", ) - parser.add_option( + _ = parser.add_option( "-v", "--verbose", dest="verbose", action="count", help="log more details (use twice for even more)", ) - parser.add_option( + _ = parser.add_option( "-c", "--config", dest="config", help="path to configuration file" ) def parse_csl_callback( option: optparse.Option, _, value: str, parser: SubcommandsOptionParser - ): + ) -> None: """Parse a comma-separated list of values.""" setattr( parser.values, @@ -1682,7 +275,7 @@ def parse_csl_callback( and subargs[0] == "config" and ("-e" in subargs or "--edit" in subargs) ): - from beets.ui.commands import config_edit + from beets.ui.commands.config import config_edit return config_edit() @@ -1699,7 +292,7 @@ def parse_csl_callback( lib._close() -def main(args=None): +def main(args: list[str] | None = None) -> None: """Run the main command-line interface for beets. Includes top-level exception handlers that print friendly error messages. """ @@ -1712,7 +305,7 @@ def main(args=None): ) sys.exit(1) try: - _raw_main(args) + _raw_main(args or []) except UserError as exc: message = exc.args[0] if exc.args else None log.error("error: {}", message) diff --git a/beets/ui/_common.py b/beets/ui/_common.py new file mode 100644 index 0000000000..9915c166e6 --- /dev/null +++ b/beets/ui/_common.py @@ -0,0 +1,4 @@ +class UserError(Exception): + """UI exception. Commands should throw this in order to display + nonrecoverable errors to the user. + """ diff --git a/beets/ui/colors.py b/beets/ui/colors.py new file mode 100644 index 0000000000..e79d4a61f2 --- /dev/null +++ b/beets/ui/colors.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import os +import re +from difflib import SequenceMatcher +from functools import cache +from itertools import chain +from typing import Final, Literal + +from beets import config, util +from beets.ui._common import UserError + +# Colorization. + +# ANSI terminal colorization code heavily inspired by pygments: +# https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py +# (pygments is by Tim Hatch, Armin Ronacher, et al.) + +COLOR_ESCAPE: Final = "\x1b" +LEGACY_COLORS: Final = { + "black": ["black"], + "darkred": ["red"], + "darkgreen": ["green"], + "brown": ["yellow"], + "darkyellow": ["yellow"], + "darkblue": ["blue"], + "purple": ["magenta"], + "darkmagenta": ["magenta"], + "teal": ["cyan"], + "darkcyan": ["cyan"], + "lightgray": ["white"], + "darkgray": ["bold", "black"], + "red": ["bold", "red"], + "green": ["bold", "green"], + "yellow": ["bold", "yellow"], + "blue": ["bold", "blue"], + "fuchsia": ["bold", "magenta"], + "magenta": ["bold", "magenta"], + "turquoise": ["bold", "cyan"], + "cyan": ["bold", "cyan"], + "white": ["bold", "white"], +} +# All ANSI Colors. +CODE_BY_COLOR: Final = { + # Styles. + "normal": 0, + "bold": 1, + "faint": 2, + # "italic": 3, + "underline": 4, + # "blink_slow": 5, + # "blink_rapid": 6, + "inverse": 7, + # "conceal": 8, + # "crossed_out": 9 + # Text colors. + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, + # Background colors. + "bg_black": 40, + "bg_red": 41, + "bg_green": 42, + "bg_yellow": 43, + "bg_blue": 44, + "bg_magenta": 45, + "bg_cyan": 46, + "bg_white": 47, +} +RESET_COLOR: Final = f"{COLOR_ESCAPE}[39;49;00m" +# Precompile common ANSI-escape regex patterns +ANSI_CODE_REGEX: Final = re.compile(rf"({COLOR_ESCAPE}\[[;0-9]*m)") +ESC_TEXT_REGEX: Final = re.compile( + rf"""(?P[^{COLOR_ESCAPE}]*) + (?P(?:{ANSI_CODE_REGEX.pattern})+) + (?P[^{COLOR_ESCAPE}]+)(?P{re.escape(RESET_COLOR)}) + (?P[^{COLOR_ESCAPE}]*)""", + re.VERBOSE, +) +ColorName = Literal[ + "text_success", + "text_warning", + "text_error", + "text_highlight", + "text_highlight_minor", + "action_default", + "action", + # New Colors + "text_faint", + "import_path", + "import_path_items", + "action_description", + "changed", + "text_diff_added", + "text_diff_removed", +] + + +@cache +def get_color_config() -> dict[ColorName, str]: + """Parse and validate color configuration, converting names to ANSI codes. + + Processes the UI color configuration, handling both new list format and + legacy single-color format. Validates all color names against known codes + and raises an error for any invalid entries. + """ + colors_by_color_name: dict[ColorName, list[str]] = { + k: (v if isinstance(v, list) else LEGACY_COLORS.get(v, [v])) + for k, v in config["ui"]["colors"].flatten().items() + } + + invalid_colors: set[str] + if invalid_colors := ( + set(chain.from_iterable(colors_by_color_name.values())) + - CODE_BY_COLOR.keys() + ): + raise UserError( + f"Invalid color(s) in configuration: {', '.join(invalid_colors)}" + ) + + return { + n: ";".join(str(CODE_BY_COLOR[c]) for c in colors) + for n, colors in colors_by_color_name.items() + } + + +def colorize(color_name: ColorName, text: str) -> str: + """Apply ANSI color formatting to text based on configuration settings. + + Returns colored text when color output is enabled and NO_COLOR environment + variable is not set, otherwise returns plain text unchanged. + """ + if config["ui"]["color"] and "NO_COLOR" not in os.environ: + color_code: str = get_color_config()[color_name] + return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}" + + return text + + +def uncolorize(colored_text: str) -> str: + """Remove colors from a string.""" + # Define a regular expression to match ANSI codes. + # See: http://stackoverflow.com/a/2187024/1382707 + # Explanation of regular expression: + # \x1b - matches ESC character + # \[ - matches opening square bracket + # [;\d]* - matches a sequence consisting of one or more digits or + # semicola + # [A-Za-z] - matches a letter + return ANSI_CODE_REGEX.sub("", colored_text) + + +def color_split(colored_text: str, index: int) -> tuple[str, str]: + length: int = 0 + pre_split: str = "" + post_split: str = "" + found_color_code: str | None = None + found_split: bool = False + part: str + for part in ANSI_CODE_REGEX.split(colored_text) or (): + # Count how many real letters we have passed + length += color_len(part) + if found_split: + post_split += part + else: + if ANSI_CODE_REGEX.match(part): + # This is a color code + found_color_code = None if part == RESET_COLOR else part + pre_split += part + else: + if index < length: + # Found part with our split in. + split_index: int = index - (length - color_len(part)) + found_split = True + if found_color_code: + pre_split += f"{part[:split_index]}{RESET_COLOR}" + post_split += f"{found_color_code}{part[split_index:]}" + else: + pre_split += part[:split_index] + post_split += part[split_index:] + else: + # Not found, add this part to the pre split + pre_split += part + return pre_split, post_split + + +def color_len(colored_text: str) -> int: + """Measure the length of a string while excluding ANSI codes from the + measurement. The standard `len(my_string)` method also counts ANSI codes + to the string length, which is counterproductive when layouting a + Terminal interface. + """ + # Return the length of the uncolored string. + return len(uncolorize(colored_text)) + + +def _colordiff(a: object, b: object) -> tuple[str, str]: + """Given two values, return the same pair of strings except with + their differences highlighted in the specified color. Strings are + highlighted intelligently to show differences; other values are + stringified and highlighted in their entirety. + """ + # First, convert paths to readable format + value: object + for value in a, b: + if isinstance(value, bytes): + # A path field. + value = util.displayable_path(value) + + if not isinstance(a, str) or not isinstance(b, str): + # Non-strings: use ordinary equality. + if a == b: + return str(a), str(b) + else: + return ( + colorize("text_diff_removed", str(a)), + colorize("text_diff_added", str(b)), + ) + + before: str = "" + after: str = "" + + op: str + a_start: int + a_end: int + b_start: int + b_end: int + matcher: SequenceMatcher[str] = SequenceMatcher(lambda x: False, a, b) + for op, a_start, a_end, b_start, b_end in matcher.get_opcodes(): + before_part: str + after_part: str + before_part, after_part = a[a_start:a_end], b[b_start:b_end] + if op in {"delete", "replace"}: + before_part = colorize("text_diff_removed", before_part) + if op in {"insert", "replace"}: + after_part = colorize("text_diff_added", after_part) + + before += before_part + after += after_part + + return before, after + + +def colordiff(a: object, b: object) -> tuple[str, str]: + """Colorize differences between two values if color is enabled. + (Like _colordiff but conditional.) + """ + return _colordiff(a, b) if config["ui"]["color"] else (str(a), str(b)) diff --git a/beets/ui/commands.py b/beets/ui/commands.py deleted file mode 100755 index b52e965b7a..0000000000 --- a/beets/ui/commands.py +++ /dev/null @@ -1,2490 +0,0 @@ -# This file is part of beets. -# Copyright 2016, Adrian Sampson. -# -# 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. - -"""This module provides the default commands for beets' command-line -interface. -""" - -import os -import re -import textwrap -from collections import Counter -from collections.abc import Sequence -from functools import cached_property -from itertools import chain -from platform import python_version -from typing import Any, NamedTuple - -import beets -from beets import autotag, config, importer, library, logging, plugins, ui, util -from beets.autotag import Recommendation, hooks -from beets.ui import ( - input_, - print_, - print_column_layout, - print_newline_layout, - show_path_changes, -) -from beets.util import ( - MoveOperation, - ancestry, - displayable_path, - functemplate, - normpath, - syspath, -) -from beets.util.units import human_bytes, human_seconds, human_seconds_short - -from . import _store_dict - -VARIOUS_ARTISTS = "Various Artists" - -# Global logger. -log = logging.getLogger("beets") - -# The list of default subcommands. This is populated with Subcommand -# objects that can be fed to a SubcommandsOptionParser. -default_commands = [] - - -# Utilities. - - -def _do_query(lib, query, album, also_items=True): - """For commands that operate on matched items, performs a query - and returns a list of matching items and a list of matching - albums. (The latter is only nonempty when album is True.) Raises - a UserError if no items match. also_items controls whether, when - fetching albums, the associated items should be fetched also. - """ - if album: - albums = list(lib.albums(query)) - items = [] - if also_items: - for al in albums: - items += al.items() - - else: - albums = [] - items = list(lib.items(query)) - - if album and not albums: - raise ui.UserError("No matching albums found.") - elif not album and not items: - raise ui.UserError("No matching items found.") - - return items, albums - - -def _paths_from_logfile(path): - """Parse the logfile and yield skipped paths to pass to the `import` - command. - """ - with open(path, encoding="utf-8") as fp: - for i, line in enumerate(fp, start=1): - verb, sep, paths = line.rstrip("\n").partition(" ") - if not sep: - raise ValueError(f"line {i} is invalid") - - # Ignore informational lines that don't need to be re-imported. - if verb in {"import", "duplicate-keep", "duplicate-replace"}: - continue - - if verb not in {"asis", "skip", "duplicate-skip"}: - raise ValueError(f"line {i} contains unknown verb {verb}") - - yield os.path.commonpath(paths.split("; ")) - - -def _parse_logfiles(logfiles): - """Parse all `logfiles` and yield paths from it.""" - for logfile in logfiles: - try: - yield from _paths_from_logfile(syspath(normpath(logfile))) - except ValueError as err: - raise ui.UserError( - f"malformed logfile {util.displayable_path(logfile)}: {err}" - ) from err - except OSError as err: - raise ui.UserError( - f"unreadable logfile {util.displayable_path(logfile)}: {err}" - ) from err - - -# fields: Shows a list of available fields for queries and format strings. - - -def _print_keys(query): - """Given a SQLite query result, print the `key` field of each - returned row, with indentation of 2 spaces. - """ - for row in query: - print_(f" {row['key']}") - - -def fields_func(lib, opts, args): - def _print_rows(names): - names.sort() - print_(textwrap.indent("\n".join(names), " ")) - - print_("Item fields:") - _print_rows(library.Item.all_keys()) - - print_("Album fields:") - _print_rows(library.Album.all_keys()) - - with lib.transaction() as tx: - # The SQL uses the DISTINCT to get unique values from the query - unique_fields = "SELECT DISTINCT key FROM ({})" - - print_("Item flexible attributes:") - _print_keys(tx.query(unique_fields.format(library.Item._flex_table))) - - print_("Album flexible attributes:") - _print_keys(tx.query(unique_fields.format(library.Album._flex_table))) - - -fields_cmd = ui.Subcommand( - "fields", help="show fields available for queries and format strings" -) -fields_cmd.func = fields_func -default_commands.append(fields_cmd) - - -# help: Print help text for commands - - -class HelpCommand(ui.Subcommand): - def __init__(self): - super().__init__( - "help", - aliases=("?",), - help="give detailed help on a specific sub-command", - ) - - def func(self, lib, opts, args): - if args: - cmdname = args[0] - helpcommand = self.root_parser._subcommand_for_name(cmdname) - if not helpcommand: - raise ui.UserError(f"unknown command '{cmdname}'") - helpcommand.print_help() - else: - self.root_parser.print_help() - - -default_commands.append(HelpCommand()) - - -# import: Autotagger and importer. - -# Importer utilities and support. - - -def disambig_string(info): - """Generate a string for an AlbumInfo or TrackInfo object that - provides context that helps disambiguate similar-looking albums and - tracks. - """ - if isinstance(info, hooks.AlbumInfo): - disambig = get_album_disambig_fields(info) - elif isinstance(info, hooks.TrackInfo): - disambig = get_singleton_disambig_fields(info) - else: - return "" - - return ", ".join(disambig) - - -def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]: - out = [] - chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq() - calculated_values = { - "index": f"Index {info.index}", - "track_alt": f"Track {info.track_alt}", - "album": ( - f"[{info.album}]" - if ( - config["import"]["singleton_album_disambig"].get() - and info.get("album") - ) - else "" - ), - } - - for field in chosen_fields: - if field in calculated_values: - out.append(str(calculated_values[field])) - else: - try: - out.append(str(info[field])) - except (AttributeError, KeyError): - print(f"Disambiguation string key {field} does not exist.") - - return out - - -def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]: - out = [] - chosen_fields = config["match"]["album_disambig_fields"].as_str_seq() - calculated_values = { - "media": ( - f"{info.mediums}x{info.media}" - if (info.mediums and info.mediums > 1) - else info.media - ), - } - - for field in chosen_fields: - if field in calculated_values: - out.append(str(calculated_values[field])) - else: - try: - out.append(str(info[field])) - except (AttributeError, KeyError): - print(f"Disambiguation string key {field} does not exist.") - - return out - - -def dist_colorize(string, dist): - """Formats a string as a colorized similarity string according to - a distance. - """ - if dist <= config["match"]["strong_rec_thresh"].as_number(): - string = ui.colorize("text_success", string) - elif dist <= config["match"]["medium_rec_thresh"].as_number(): - string = ui.colorize("text_warning", string) - else: - string = ui.colorize("text_error", string) - return string - - -def dist_string(dist): - """Formats a distance (a float) as a colorized similarity percentage - string. - """ - string = f"{(1 - dist) * 100:.1f}%" - return dist_colorize(string, dist) - - -def penalty_string(distance, limit=None): - """Returns a colorized string that indicates all the penalties - applied to a distance object. - """ - penalties = [] - for key in distance.keys(): - key = key.replace("album_", "") - key = key.replace("track_", "") - key = key.replace("_", " ") - penalties.append(key) - if penalties: - if limit and len(penalties) > limit: - penalties = penalties[:limit] + ["..."] - # Prefix penalty string with U+2260: Not Equal To - penalty_string = f"\u2260 {', '.join(penalties)}" - return ui.colorize("changed", penalty_string) - - -class ChangeRepresentation: - """Keeps track of all information needed to generate a (colored) text - representation of the changes that will be made if an album or singleton's - tags are changed according to `match`, which must be an AlbumMatch or - TrackMatch object, accordingly. - """ - - @cached_property - def changed_prefix(self) -> str: - return ui.colorize("changed", "\u2260") - - cur_artist = None - # cur_album set if album, cur_title set if singleton - cur_album = None - cur_title = None - match = None - indent_header = "" - indent_detail = "" - - def __init__(self): - # Read match header indentation width from config. - match_header_indent_width = config["ui"]["import"]["indentation"][ - "match_header" - ].as_number() - self.indent_header = ui.indent(match_header_indent_width) - - # Read match detail indentation width from config. - match_detail_indent_width = config["ui"]["import"]["indentation"][ - "match_details" - ].as_number() - self.indent_detail = ui.indent(match_detail_indent_width) - - # Read match tracklist indentation width from config - match_tracklist_indent_width = config["ui"]["import"]["indentation"][ - "match_tracklist" - ].as_number() - self.indent_tracklist = ui.indent(match_tracklist_indent_width) - self.layout = config["ui"]["import"]["layout"].as_choice( - { - "column": 0, - "newline": 1, - } - ) - - def print_layout( - self, indent, left, right, separator=" -> ", max_width=None - ): - if not max_width: - # If no max_width provided, use terminal width - max_width = ui.term_width() - if self.layout == 0: - print_column_layout(indent, left, right, separator, max_width) - else: - print_newline_layout(indent, left, right, separator, max_width) - - def show_match_header(self): - """Print out a 'header' identifying the suggested match (album name, - artist name,...) and summarizing the changes that would be made should - the user accept the match. - """ - # Print newline at beginning of change block. - print_("") - - # 'Match' line and similarity. - print_( - f"{self.indent_header}Match ({dist_string(self.match.distance)}):" - ) - - if isinstance(self.match.info, autotag.hooks.AlbumInfo): - # Matching an album - print that - artist_album_str = ( - f"{self.match.info.artist} - {self.match.info.album}" - ) - else: - # Matching a single track - artist_album_str = ( - f"{self.match.info.artist} - {self.match.info.title}" - ) - print_( - self.indent_header - + dist_colorize(artist_album_str, self.match.distance) - ) - - # Penalties. - penalties = penalty_string(self.match.distance) - if penalties: - print_(f"{self.indent_header}{penalties}") - - # Disambiguation. - disambig = disambig_string(self.match.info) - if disambig: - print_(f"{self.indent_header}{disambig}") - - # Data URL. - if self.match.info.data_url: - url = ui.colorize("text_faint", f"{self.match.info.data_url}") - print_(f"{self.indent_header}{url}") - - def show_match_details(self): - """Print out the details of the match, including changes in album name - and artist name. - """ - # Artist. - artist_l, artist_r = self.cur_artist or "", self.match.info.artist - if artist_r == VARIOUS_ARTISTS: - # Hide artists for VA releases. - artist_l, artist_r = "", "" - if artist_l != artist_r: - artist_l, artist_r = ui.colordiff(artist_l, artist_r) - left = { - "prefix": f"{self.changed_prefix} Artist: ", - "contents": artist_l, - "suffix": "", - } - right = {"prefix": "", "contents": artist_r, "suffix": ""} - self.print_layout(self.indent_detail, left, right) - - else: - print_(f"{self.indent_detail}*", "Artist:", artist_r) - - if self.cur_album: - # Album - album_l, album_r = self.cur_album or "", self.match.info.album - if ( - self.cur_album != self.match.info.album - and self.match.info.album != VARIOUS_ARTISTS - ): - album_l, album_r = ui.colordiff(album_l, album_r) - left = { - "prefix": f"{self.changed_prefix} Album: ", - "contents": album_l, - "suffix": "", - } - right = {"prefix": "", "contents": album_r, "suffix": ""} - self.print_layout(self.indent_detail, left, right) - else: - print_(f"{self.indent_detail}*", "Album:", album_r) - elif self.cur_title: - # Title - for singletons - title_l, title_r = self.cur_title or "", self.match.info.title - if self.cur_title != self.match.info.title: - title_l, title_r = ui.colordiff(title_l, title_r) - left = { - "prefix": f"{self.changed_prefix} Title: ", - "contents": title_l, - "suffix": "", - } - right = {"prefix": "", "contents": title_r, "suffix": ""} - self.print_layout(self.indent_detail, left, right) - else: - print_(f"{self.indent_detail}*", "Title:", title_r) - - def make_medium_info_line(self, track_info): - """Construct a line with the current medium's info.""" - track_media = track_info.get("media", "Media") - # Build output string. - if self.match.info.mediums > 1 and track_info.disctitle: - return ( - f"* {track_media} {track_info.medium}: {track_info.disctitle}" - ) - elif self.match.info.mediums > 1: - return f"* {track_media} {track_info.medium}" - elif track_info.disctitle: - return f"* {track_media}: {track_info.disctitle}" - else: - return "" - - def format_index(self, track_info): - """Return a string representing the track index of the given - TrackInfo or Item object. - """ - if isinstance(track_info, hooks.TrackInfo): - index = track_info.index - medium_index = track_info.medium_index - medium = track_info.medium - mediums = self.match.info.mediums - else: - index = medium_index = track_info.track - medium = track_info.disc - mediums = track_info.disctotal - if config["per_disc_numbering"]: - if mediums and mediums > 1: - return f"{medium}-{medium_index}" - else: - return str(medium_index if medium_index is not None else index) - else: - return str(index) - - def make_track_numbers(self, item, track_info): - """Format colored track indices.""" - cur_track = self.format_index(item) - new_track = self.format_index(track_info) - changed = False - # Choose color based on change. - if cur_track != new_track: - changed = True - if item.track in (track_info.index, track_info.medium_index): - highlight_color = "text_highlight_minor" - else: - highlight_color = "text_highlight" - else: - highlight_color = "text_faint" - - lhs_track = ui.colorize(highlight_color, f"(#{cur_track})") - rhs_track = ui.colorize(highlight_color, f"(#{new_track})") - return lhs_track, rhs_track, changed - - @staticmethod - def make_track_titles(item, track_info): - """Format colored track titles.""" - new_title = track_info.title - if not item.title.strip(): - # If there's no title, we use the filename. Don't colordiff. - cur_title = displayable_path(os.path.basename(item.path)) - return cur_title, new_title, True - else: - # If there is a title, highlight differences. - cur_title = item.title.strip() - cur_col, new_col = ui.colordiff(cur_title, new_title) - return cur_col, new_col, cur_title != new_title - - @staticmethod - def make_track_lengths(item, track_info): - """Format colored track lengths.""" - changed = False - if ( - item.length - and track_info.length - and abs(item.length - track_info.length) - >= config["ui"]["length_diff_thresh"].as_number() - ): - highlight_color = "text_highlight" - changed = True - else: - highlight_color = "text_highlight_minor" - - # Handle nonetype lengths by setting to 0 - cur_length0 = item.length if item.length else 0 - new_length0 = track_info.length if track_info.length else 0 - # format into string - cur_length = f"({human_seconds_short(cur_length0)})" - new_length = f"({human_seconds_short(new_length0)})" - # colorize - lhs_length = ui.colorize(highlight_color, cur_length) - rhs_length = ui.colorize(highlight_color, new_length) - - return lhs_length, rhs_length, changed - - def make_line(self, item, track_info): - """Extract changes from item -> new TrackInfo object, and colorize - appropriately. Returns (lhs, rhs) for column printing. - """ - # Track titles. - lhs_title, rhs_title, diff_title = self.make_track_titles( - item, track_info - ) - # Track number change. - lhs_track, rhs_track, diff_track = self.make_track_numbers( - item, track_info - ) - # Length change. - lhs_length, rhs_length, diff_length = self.make_track_lengths( - item, track_info - ) - - changed = diff_title or diff_track or diff_length - - # Construct lhs and rhs dicts. - # Previously, we printed the penalties, however this is no longer - # the case, thus the 'info' dictionary is unneeded. - # penalties = penalty_string(self.match.distance.tracks[track_info]) - - lhs = { - "prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ", - "contents": lhs_title, - "suffix": f" {lhs_length}", - } - rhs = {"prefix": "", "contents": "", "suffix": ""} - if not changed: - # Only return the left side, as nothing changed. - return (lhs, rhs) - else: - # Construct a dictionary for the "changed to" side - rhs = { - "prefix": f"{rhs_track} ", - "contents": rhs_title, - "suffix": f" {rhs_length}", - } - return (lhs, rhs) - - def print_tracklist(self, lines): - """Calculates column widths for tracks stored as line tuples: - (left, right). Then prints each line of tracklist. - """ - if len(lines) == 0: - # If no lines provided, e.g. details not required, do nothing. - return - - def get_width(side): - """Return the width of left or right in uncolorized characters.""" - try: - return len( - ui.uncolorize( - " ".join( - [side["prefix"], side["contents"], side["suffix"]] - ) - ) - ) - except KeyError: - # An empty dictionary -> Nothing to report - return 0 - - # Check how to fit content into terminal window - indent_width = len(self.indent_tracklist) - terminal_width = ui.term_width() - joiner_width = len("".join(["* ", " -> "])) - col_width = (terminal_width - indent_width - joiner_width) // 2 - max_width_l = max(get_width(line_tuple[0]) for line_tuple in lines) - max_width_r = max(get_width(line_tuple[1]) for line_tuple in lines) - - if ( - (max_width_l <= col_width) - and (max_width_r <= col_width) - or ( - ((max_width_l > col_width) or (max_width_r > col_width)) - and ((max_width_l + max_width_r) <= col_width * 2) - ) - ): - # All content fits. Either both maximum widths are below column - # widths, or one of the columns is larger than allowed but the - # other is smaller than allowed. - # In this case we can afford to shrink the columns to fit their - # largest string - col_width_l = max_width_l - col_width_r = max_width_r - else: - # Not all content fits - stick with original half/half split - col_width_l = col_width - col_width_r = col_width - - # Print out each line, using the calculated width from above. - for left, right in lines: - left["width"] = col_width_l - right["width"] = col_width_r - self.print_layout(self.indent_tracklist, left, right) - - -class AlbumChange(ChangeRepresentation): - """Album change representation, setting cur_album""" - - def __init__(self, cur_artist, cur_album, match): - super().__init__() - self.cur_artist = cur_artist - self.cur_album = cur_album - self.match = match - - def show_match_tracks(self): - """Print out the tracks of the match, summarizing changes the match - suggests for them. - """ - # Tracks. - # match is an AlbumMatch NamedTuple, mapping is a dict - # Sort the pairs by the track_info index (at index 1 of the NamedTuple) - pairs = list(self.match.mapping.items()) - pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].index) - # Build up LHS and RHS for track difference display. The `lines` list - # contains `(left, right)` tuples. - lines = [] - medium = disctitle = None - for item, track_info in pairs: - # If the track is the first on a new medium, show medium - # number and title. - if medium != track_info.medium or disctitle != track_info.disctitle: - # Create header for new medium - header = self.make_medium_info_line(track_info) - if header != "": - # Print tracks from previous medium - self.print_tracklist(lines) - lines = [] - print_(f"{self.indent_detail}{header}") - # Save new medium details for future comparison. - medium, disctitle = track_info.medium, track_info.disctitle - - # Construct the line tuple for the track. - left, right = self.make_line(item, track_info) - if right["contents"] != "": - lines.append((left, right)) - else: - if config["import"]["detail"]: - lines.append((left, right)) - self.print_tracklist(lines) - - # Missing and unmatched tracks. - if self.match.extra_tracks: - print_( - "Missing tracks" - f" ({len(self.match.extra_tracks)}/{len(self.match.info.tracks)} -" - f" {len(self.match.extra_tracks) / len(self.match.info.tracks):.1%}):" - ) - for track_info in self.match.extra_tracks: - line = f" ! {track_info.title} (#{self.format_index(track_info)})" - if track_info.length: - line += f" ({human_seconds_short(track_info.length)})" - print_(ui.colorize("text_warning", line)) - if self.match.extra_items: - print_(f"Unmatched tracks ({len(self.match.extra_items)}):") - for item in self.match.extra_items: - line = f" ! {item.title} (#{self.format_index(item)})" - if item.length: - line += f" ({human_seconds_short(item.length)})" - print_(ui.colorize("text_warning", line)) - - -class TrackChange(ChangeRepresentation): - """Track change representation, comparing item with match.""" - - def __init__(self, cur_artist, cur_title, match): - super().__init__() - self.cur_artist = cur_artist - self.cur_title = cur_title - self.match = match - - -def show_change(cur_artist, cur_album, match): - """Print out a representation of the changes that will be made if an - album's tags are changed according to `match`, which must be an AlbumMatch - object. - """ - change = AlbumChange( - cur_artist=cur_artist, cur_album=cur_album, match=match - ) - - # Print the match header. - change.show_match_header() - - # Print the match details. - change.show_match_details() - - # Print the match tracks. - change.show_match_tracks() - - -def show_item_change(item, match): - """Print out the change that would occur by tagging `item` with the - metadata from `match`, a TrackMatch object. - """ - change = TrackChange( - cur_artist=item.artist, cur_title=item.title, match=match - ) - # Print the match header. - change.show_match_header() - # Print the match details. - change.show_match_details() - - -def summarize_items(items, singleton): - """Produces a brief summary line describing a set of items. Used for - manually resolving duplicates during import. - - `items` is a list of `Item` objects. `singleton` indicates whether - this is an album or single-item import (if the latter, them `items` - should only have one element). - """ - summary_parts = [] - if not singleton: - summary_parts.append(f"{len(items)} items") - - format_counts = {} - for item in items: - format_counts[item.format] = format_counts.get(item.format, 0) + 1 - if len(format_counts) == 1: - # A single format. - summary_parts.append(items[0].format) - else: - # Enumerate all the formats by decreasing frequencies: - for fmt, count in sorted( - format_counts.items(), - key=lambda fmt_and_count: (-fmt_and_count[1], fmt_and_count[0]), - ): - summary_parts.append(f"{fmt} {count}") - - if items: - average_bitrate = sum([item.bitrate for item in items]) / len(items) - total_duration = sum([item.length for item in items]) - total_filesize = sum([item.filesize for item in items]) - summary_parts.append(f"{int(average_bitrate / 1000)}kbps") - if items[0].format == "FLAC": - sample_bits = ( - f"{round(int(items[0].samplerate) / 1000, 1)}kHz" - f"/{items[0].bitdepth} bit" - ) - summary_parts.append(sample_bits) - summary_parts.append(human_seconds_short(total_duration)) - summary_parts.append(human_bytes(total_filesize)) - - return ", ".join(summary_parts) - - -def _summary_judgment(rec): - """Determines whether a decision should be made without even asking - the user. This occurs in quiet mode and when an action is chosen for - NONE recommendations. Return None if the user should be queried. - Otherwise, returns an action. May also print to the console if a - summary judgment is made. - """ - - if config["import"]["quiet"]: - if rec == Recommendation.strong: - return importer.Action.APPLY - else: - action = config["import"]["quiet_fallback"].as_choice( - { - "skip": importer.Action.SKIP, - "asis": importer.Action.ASIS, - } - ) - elif config["import"]["timid"]: - return None - elif rec == Recommendation.none: - action = config["import"]["none_rec_action"].as_choice( - { - "skip": importer.Action.SKIP, - "asis": importer.Action.ASIS, - "ask": None, - } - ) - else: - return None - - if action == importer.Action.SKIP: - print_("Skipping.") - elif action == importer.Action.ASIS: - print_("Importing as-is.") - return action - - -class PromptChoice(NamedTuple): - short: str - long: str - callback: Any - - -def choose_candidate( - candidates, - singleton, - rec, - cur_artist=None, - cur_album=None, - item=None, - itemcount=None, - choices=[], -): - """Given a sorted list of candidates, ask the user for a selection - of which candidate to use. Applies to both full albums and - singletons (tracks). Candidates are either AlbumMatch or TrackMatch - objects depending on `singleton`. for albums, `cur_artist`, - `cur_album`, and `itemcount` must be provided. For singletons, - `item` must be provided. - - `choices` is a list of `PromptChoice`s to be used in each prompt. - - Returns one of the following: - * the result of the choice, which may be SKIP or ASIS - * a candidate (an AlbumMatch/TrackMatch object) - * a chosen `PromptChoice` from `choices` - """ - # Sanity check. - if singleton: - assert item is not None - else: - assert cur_artist is not None - assert cur_album is not None - - # Build helper variables for the prompt choices. - choice_opts = tuple(c.long for c in choices) - choice_actions = {c.short: c for c in choices} - - # Zero candidates. - if not candidates: - if singleton: - print_("No matching recordings found.") - else: - print_(f"No matching release found for {itemcount} tracks.") - print_( - "For help, see: " - "https://beets.readthedocs.org/en/latest/faq.html#nomatch" - ) - sel = ui.input_options(choice_opts) - if sel in choice_actions: - return choice_actions[sel] - else: - assert False - - # Is the change good enough? - bypass_candidates = False - if rec != Recommendation.none: - match = candidates[0] - bypass_candidates = True - - while True: - # Display and choose from candidates. - require = rec <= Recommendation.low - - if not bypass_candidates: - # Display list of candidates. - print_("") - print_( - f"Finding tags for {'track' if singleton else 'album'} " - f'"{item.artist if singleton else cur_artist} -' - f' {item.title if singleton else cur_album}".' - ) - - print_(" Candidates:") - for i, match in enumerate(candidates): - # Index, metadata, and distance. - index0 = f"{i + 1}." - index = dist_colorize(index0, match.distance) - dist = f"({(1 - match.distance) * 100:.1f}%)" - distance = dist_colorize(dist, match.distance) - metadata = ( - f"{match.info.artist} -" - f" {match.info.title if singleton else match.info.album}" - ) - if i == 0: - metadata = dist_colorize(metadata, match.distance) - else: - metadata = ui.colorize("text_highlight_minor", metadata) - line1 = [index, distance, metadata] - print_(f" {' '.join(line1)}") - - # Penalties. - penalties = penalty_string(match.distance, 3) - if penalties: - print_(f"{' ' * 13}{penalties}") - - # Disambiguation - disambig = disambig_string(match.info) - if disambig: - print_(f"{' ' * 13}{disambig}") - - # Ask the user for a choice. - sel = ui.input_options(choice_opts, numrange=(1, len(candidates))) - if sel == "m": - pass - elif sel in choice_actions: - return choice_actions[sel] - else: # Numerical selection. - match = candidates[sel - 1] - if sel != 1: - # When choosing anything but the first match, - # disable the default action. - require = True - bypass_candidates = False - - # Show what we're about to do. - if singleton: - show_item_change(item, match) - else: - show_change(cur_artist, cur_album, match) - - # Exact match => tag automatically if we're not in timid mode. - if rec == Recommendation.strong and not config["import"]["timid"]: - return match - - # Ask for confirmation. - default = config["import"]["default_action"].as_choice( - { - "apply": "a", - "skip": "s", - "asis": "u", - "none": None, - } - ) - if default is None: - require = True - # Bell ring when user interaction is needed. - if config["import"]["bell"]: - ui.print_("\a", end="") - sel = ui.input_options( - ("Apply", "More candidates") + choice_opts, - require=require, - default=default, - ) - if sel == "a": - return match - elif sel in choice_actions: - return choice_actions[sel] - - -def manual_search(session, task): - """Get a new `Proposal` using manual search criteria. - - Input either an artist and album (for full albums) or artist and - track name (for singletons) for manual search. - """ - artist = input_("Artist:").strip() - name = input_("Album:" if task.is_album else "Track:").strip() - - if task.is_album: - _, _, prop = autotag.tag_album(task.items, artist, name) - return prop - else: - return autotag.tag_item(task.item, artist, name) - - -def manual_id(session, task): - """Get a new `Proposal` using a manually-entered ID. - - Input an ID, either for an album ("release") or a track ("recording"). - """ - prompt = f"Enter {'release' if task.is_album else 'recording'} ID:" - search_id = input_(prompt).strip() - - if task.is_album: - _, _, prop = autotag.tag_album(task.items, search_ids=search_id.split()) - return prop - else: - return autotag.tag_item(task.item, search_ids=search_id.split()) - - -def abort_action(session, task): - """A prompt choice callback that aborts the importer.""" - raise importer.ImportAbortError() - - -class TerminalImportSession(importer.ImportSession): - """An import session that runs in a terminal.""" - - def choose_match(self, task): - """Given an initial autotagging of items, go through an interactive - dance with the user to ask for a choice of metadata. Returns an - AlbumMatch object, ASIS, or SKIP. - """ - # Show what we're tagging. - print_() - - path_str0 = displayable_path(task.paths, "\n") - path_str = ui.colorize("import_path", path_str0) - items_str0 = f"({len(task.items)} items)" - items_str = ui.colorize("import_path_items", items_str0) - print_(" ".join([path_str, items_str])) - - # Let plugins display info or prompt the user before we go through the - # process of selecting candidate. - results = plugins.send( - "import_task_before_choice", session=self, task=task - ) - actions = [action for action in results if action] - - if len(actions) == 1: - return actions[0] - elif len(actions) > 1: - raise plugins.PluginConflictError( - "Only one handler for `import_task_before_choice` may return " - "an action." - ) - - # Take immediate action if appropriate. - action = _summary_judgment(task.rec) - if action == importer.Action.APPLY: - match = task.candidates[0] - show_change(task.cur_artist, task.cur_album, match) - return match - elif action is not None: - return action - - # Loop until we have a choice. - while True: - # Ask for a choice from the user. The result of - # `choose_candidate` may be an `importer.Action`, an - # `AlbumMatch` object for a specific selection, or a - # `PromptChoice`. - choices = self._get_choices(task) - choice = choose_candidate( - task.candidates, - False, - task.rec, - task.cur_artist, - task.cur_album, - itemcount=len(task.items), - choices=choices, - ) - - # Basic choices that require no more action here. - if choice in (importer.Action.SKIP, importer.Action.ASIS): - # Pass selection to main control flow. - return choice - - # Plugin-provided choices. We invoke the associated callback - # function. - elif choice in choices: - post_choice = choice.callback(self, task) - if isinstance(post_choice, importer.Action): - return post_choice - elif isinstance(post_choice, autotag.Proposal): - # Use the new candidates and continue around the loop. - task.candidates = post_choice.candidates - task.rec = post_choice.recommendation - - # Otherwise, we have a specific match selection. - else: - # We have a candidate! Finish tagging. Here, choice is an - # AlbumMatch object. - assert isinstance(choice, autotag.AlbumMatch) - return choice - - def choose_item(self, task): - """Ask the user for a choice about tagging a single item. Returns - either an action constant or a TrackMatch object. - """ - print_() - print_(displayable_path(task.item.path)) - candidates, rec = task.candidates, task.rec - - # Take immediate action if appropriate. - action = _summary_judgment(task.rec) - if action == importer.Action.APPLY: - match = candidates[0] - show_item_change(task.item, match) - return match - elif action is not None: - return action - - while True: - # Ask for a choice. - choices = self._get_choices(task) - choice = choose_candidate( - candidates, True, rec, item=task.item, choices=choices - ) - - if choice in (importer.Action.SKIP, importer.Action.ASIS): - return choice - - elif choice in choices: - post_choice = choice.callback(self, task) - if isinstance(post_choice, importer.Action): - return post_choice - elif isinstance(post_choice, autotag.Proposal): - candidates = post_choice.candidates - rec = post_choice.recommendation - - else: - # Chose a candidate. - assert isinstance(choice, autotag.TrackMatch) - return choice - - def resolve_duplicate(self, task, found_duplicates): - """Decide what to do when a new album or item seems similar to one - that's already in the library. - """ - log.warning( - "This {} is already in the library!", - ("album" if task.is_album else "item"), - ) - - if config["import"]["quiet"]: - # In quiet mode, don't prompt -- just skip. - log.info("Skipping.") - sel = "s" - else: - # Print some detail about the existing and new items so the - # user can make an informed decision. - for duplicate in found_duplicates: - print_( - "Old: " - + summarize_items( - ( - list(duplicate.items()) - if task.is_album - else [duplicate] - ), - not task.is_album, - ) - ) - if config["import"]["duplicate_verbose_prompt"]: - if task.is_album: - for dup in duplicate.items(): - print(f" {dup}") - else: - print(f" {duplicate}") - - print_( - "New: " - + summarize_items( - task.imported_items(), - not task.is_album, - ) - ) - if config["import"]["duplicate_verbose_prompt"]: - for item in task.imported_items(): - print(f" {item}") - - sel = ui.input_options( - ("Skip new", "Keep all", "Remove old", "Merge all") - ) - - if sel == "s": - # Skip new. - task.set_choice(importer.Action.SKIP) - elif sel == "k": - # Keep both. Do nothing; leave the choice intact. - pass - elif sel == "r": - # Remove old. - task.should_remove_duplicates = True - elif sel == "m": - task.should_merge_duplicates = True - else: - assert False - - def should_resume(self, path): - return ui.input_yn( - f"Import of the directory:\n{displayable_path(path)}\n" - "was interrupted. Resume (Y/n)?" - ) - - def _get_choices(self, task): - """Get the list of prompt choices that should be presented to the - user. This consists of both built-in choices and ones provided by - plugins. - - The `before_choose_candidate` event is sent to the plugins, with - session and task as its parameters. Plugins are responsible for - checking the right conditions and returning a list of `PromptChoice`s, - which is flattened and checked for conflicts. - - If two or more choices have the same short letter, a warning is - emitted and all but one choices are discarded, giving preference - to the default importer choices. - - Returns a list of `PromptChoice`s. - """ - # Standard, built-in choices. - choices = [ - PromptChoice("s", "Skip", lambda s, t: importer.Action.SKIP), - PromptChoice("u", "Use as-is", lambda s, t: importer.Action.ASIS), - ] - if task.is_album: - choices += [ - PromptChoice( - "t", "as Tracks", lambda s, t: importer.Action.TRACKS - ), - PromptChoice( - "g", "Group albums", lambda s, t: importer.Action.ALBUMS - ), - ] - choices += [ - PromptChoice("e", "Enter search", manual_search), - PromptChoice("i", "enter Id", manual_id), - PromptChoice("b", "aBort", abort_action), - ] - - # Send the before_choose_candidate event and flatten list. - extra_choices = list( - chain( - *plugins.send( - "before_choose_candidate", session=self, task=task - ) - ) - ) - - # Add a "dummy" choice for the other baked-in option, for - # duplicate checking. - all_choices = ( - [ - PromptChoice("a", "Apply", None), - ] - + choices - + extra_choices - ) - - # Check for conflicts. - short_letters = [c.short for c in all_choices] - if len(short_letters) != len(set(short_letters)): - # Duplicate short letter has been found. - duplicates = [ - i for i, count in Counter(short_letters).items() if count > 1 - ] - for short in duplicates: - # Keep the first of the choices, removing the rest. - dup_choices = [c for c in all_choices if c.short == short] - for c in dup_choices[1:]: - log.warning( - "Prompt choice '{0.long}' removed due to conflict " - "with '{1[0].long}' (short letter: '{0.short}')", - c, - dup_choices, - ) - extra_choices.remove(c) - - return choices + extra_choices - - -# The import command. - - -def import_files(lib, paths: list[bytes], query): - """Import the files in the given list of paths or matching the - query. - """ - # Check parameter consistency. - if config["import"]["quiet"] and config["import"]["timid"]: - raise ui.UserError("can't be both quiet and timid") - - # Open the log. - if config["import"]["log"].get() is not None: - logpath = syspath(config["import"]["log"].as_filename()) - try: - loghandler = logging.FileHandler(logpath, encoding="utf-8") - except OSError: - raise ui.UserError( - "Could not open log file for writing:" - f" {displayable_path(logpath)}" - ) - else: - loghandler = None - - # Never ask for input in quiet mode. - if config["import"]["resume"].get() == "ask" and config["import"]["quiet"]: - config["import"]["resume"] = False - - session = TerminalImportSession(lib, loghandler, paths, query) - session.run() - - # Emit event. - plugins.send("import", lib=lib, paths=paths) - - -def import_func(lib, opts, args: list[str]): - config["import"].set_args(opts) - - # Special case: --copy flag suppresses import_move (which would - # otherwise take precedence). - if opts.copy: - config["import"]["move"] = False - - if opts.library: - query = args - byte_paths = [] - else: - query = None - paths = args - - # The paths from the logfiles go into a separate list to allow handling - # errors differently from user-specified paths. - paths_from_logfiles = list(_parse_logfiles(opts.from_logfiles or [])) - - if not paths and not paths_from_logfiles: - raise ui.UserError("no path specified") - - byte_paths = [os.fsencode(p) for p in paths] - paths_from_logfiles = [os.fsencode(p) for p in paths_from_logfiles] - - # Check the user-specified directories. - for path in byte_paths: - if not os.path.exists(syspath(normpath(path))): - raise ui.UserError( - f"no such file or directory: {displayable_path(path)}" - ) - - # Check the directories from the logfiles, but don't throw an error in - # case those paths don't exist. Maybe some of those paths have already - # been imported and moved separately, so logging a warning should - # suffice. - for path in paths_from_logfiles: - if not os.path.exists(syspath(normpath(path))): - log.warning( - "No such file or directory: {}", displayable_path(path) - ) - continue - - byte_paths.append(path) - - # If all paths were read from a logfile, and none of them exist, throw - # an error - if not paths: - raise ui.UserError("none of the paths are importable") - - import_files(lib, byte_paths, query) - - -import_cmd = ui.Subcommand( - "import", help="import new music", aliases=("imp", "im") -) -import_cmd.parser.add_option( - "-c", - "--copy", - action="store_true", - default=None, - help="copy tracks into library directory (default)", -) -import_cmd.parser.add_option( - "-C", - "--nocopy", - action="store_false", - dest="copy", - help="don't copy tracks (opposite of -c)", -) -import_cmd.parser.add_option( - "-m", - "--move", - action="store_true", - dest="move", - help="move tracks into the library (overrides -c)", -) -import_cmd.parser.add_option( - "-w", - "--write", - action="store_true", - default=None, - help="write new metadata to files' tags (default)", -) -import_cmd.parser.add_option( - "-W", - "--nowrite", - action="store_false", - dest="write", - help="don't write metadata (opposite of -w)", -) -import_cmd.parser.add_option( - "-a", - "--autotag", - action="store_true", - dest="autotag", - help="infer tags for imported files (default)", -) -import_cmd.parser.add_option( - "-A", - "--noautotag", - action="store_false", - dest="autotag", - help="don't infer tags for imported files (opposite of -a)", -) -import_cmd.parser.add_option( - "-p", - "--resume", - action="store_true", - default=None, - help="resume importing if interrupted", -) -import_cmd.parser.add_option( - "-P", - "--noresume", - action="store_false", - dest="resume", - help="do not try to resume importing", -) -import_cmd.parser.add_option( - "-q", - "--quiet", - action="store_true", - dest="quiet", - help="never prompt for input: skip albums instead", -) -import_cmd.parser.add_option( - "--quiet-fallback", - type="string", - dest="quiet_fallback", - help="decision in quiet mode when no strong match: skip or asis", -) -import_cmd.parser.add_option( - "-l", - "--log", - dest="log", - help="file to log untaggable albums for later review", -) -import_cmd.parser.add_option( - "-s", - "--singletons", - action="store_true", - help="import individual tracks instead of full albums", -) -import_cmd.parser.add_option( - "-t", - "--timid", - dest="timid", - action="store_true", - help="always confirm all actions", -) -import_cmd.parser.add_option( - "-L", - "--library", - dest="library", - action="store_true", - help="retag items matching a query", -) -import_cmd.parser.add_option( - "-i", - "--incremental", - dest="incremental", - action="store_true", - help="skip already-imported directories", -) -import_cmd.parser.add_option( - "-I", - "--noincremental", - dest="incremental", - action="store_false", - help="do not skip already-imported directories", -) -import_cmd.parser.add_option( - "-R", - "--incremental-skip-later", - action="store_true", - dest="incremental_skip_later", - help="do not record skipped files during incremental import", -) -import_cmd.parser.add_option( - "-r", - "--noincremental-skip-later", - action="store_false", - dest="incremental_skip_later", - help="record skipped files during incremental import", -) -import_cmd.parser.add_option( - "--from-scratch", - dest="from_scratch", - action="store_true", - help="erase existing metadata before applying new metadata", -) -import_cmd.parser.add_option( - "--flat", - dest="flat", - action="store_true", - help="import an entire tree as a single album", -) -import_cmd.parser.add_option( - "-g", - "--group-albums", - dest="group_albums", - action="store_true", - help="group tracks in a folder into separate albums", -) -import_cmd.parser.add_option( - "--pretend", - dest="pretend", - action="store_true", - help="just print the files to import", -) -import_cmd.parser.add_option( - "-S", - "--search-id", - dest="search_ids", - action="append", - metavar="ID", - help="restrict matching to a specific metadata backend ID", -) -import_cmd.parser.add_option( - "--from-logfile", - dest="from_logfiles", - action="append", - metavar="PATH", - help="read skipped paths from an existing logfile", -) -import_cmd.parser.add_option( - "--set", - dest="set_fields", - action="callback", - callback=_store_dict, - metavar="FIELD=VALUE", - help="set the given fields to the supplied values", -) -import_cmd.func = import_func -default_commands.append(import_cmd) - - -# list: Query and show library contents. - - -def list_items(lib, query, album, fmt=""): - """Print out items in lib matching query. If album, then search for - albums instead of single items. - """ - if album: - for album in lib.albums(query): - ui.print_(format(album, fmt)) - else: - for item in lib.items(query): - ui.print_(format(item, fmt)) - - -def list_func(lib, opts, args): - list_items(lib, args, opts.album) - - -list_cmd = ui.Subcommand("list", help="query the library", aliases=("ls",)) -list_cmd.parser.usage += "\nExample: %prog -f '$album: $title' artist:beatles" -list_cmd.parser.add_all_common_options() -list_cmd.func = list_func -default_commands.append(list_cmd) - - -# update: Update library contents according to on-disk tags. - - -def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): - """For all the items matched by the query, update the library to - reflect the item's embedded tags. - :param fields: The fields to be stored. If not specified, all fields will - be. - :param exclude_fields: The fields to not be stored. If not specified, all - fields will be. - """ - with lib.transaction(): - items, _ = _do_query(lib, query, album) - if move and fields is not None and "path" not in fields: - # Special case: if an item needs to be moved, the path field has to - # updated; otherwise the new path will not be reflected in the - # database. - fields.append("path") - if fields is None: - # no fields were provided, update all media fields - item_fields = fields or library.Item._media_fields - if move and "path" not in item_fields: - # move is enabled, add 'path' to the list of fields to update - item_fields.add("path") - else: - # fields was provided, just update those - item_fields = fields - # get all the album fields to update - album_fields = fields or library.Album._fields.keys() - if exclude_fields: - # remove any excluded fields from the item and album sets - item_fields = [f for f in item_fields if f not in exclude_fields] - album_fields = [f for f in album_fields if f not in exclude_fields] - - # Walk through the items and pick up their changes. - affected_albums = set() - for item in items: - # Item deleted? - if not item.path or not os.path.exists(syspath(item.path)): - ui.print_(format(item)) - ui.print_(ui.colorize("text_error", " deleted")) - if not pretend: - item.remove(True) - affected_albums.add(item.album_id) - continue - - # Did the item change since last checked? - if item.current_mtime() <= item.mtime: - log.debug( - "skipping {0.filepath} because mtime is up to date ({0.mtime})", - item, - ) - continue - - # Read new data. - try: - item.read() - except library.ReadError as exc: - log.error("error reading {.filepath}: {}", item, exc) - continue - - # Special-case album artist when it matches track artist. (Hacky - # but necessary for preserving album-level metadata for non- - # autotagged imports.) - if not item.albumartist: - old_item = lib.get_item(item.id) - if old_item.albumartist == old_item.artist == item.artist: - item.albumartist = old_item.albumartist - item._dirty.discard("albumartist") - - # Check for and display changes. - changed = ui.show_model_changes(item, fields=item_fields) - - # Save changes. - if not pretend: - if changed: - # Move the item if it's in the library. - if move and lib.directory in ancestry(item.path): - item.move(store=False) - - item.store(fields=item_fields) - affected_albums.add(item.album_id) - else: - # The file's mtime was different, but there were no - # changes to the metadata. Store the new mtime, - # which is set in the call to read(), so we don't - # check this again in the future. - item.store(fields=item_fields) - - # Skip album changes while pretending. - if pretend: - return - - # Modify affected albums to reflect changes in their items. - for album_id in affected_albums: - if album_id is None: # Singletons. - continue - album = lib.get_album(album_id) - if not album: # Empty albums have already been removed. - log.debug("emptied album {}", album_id) - continue - first_item = album.items().get() - - # Update album structure to reflect an item in it. - for key in library.Album.item_keys: - album[key] = first_item[key] - album.store(fields=album_fields) - - # Move album art (and any inconsistent items). - if move and lib.directory in ancestry(first_item.path): - log.debug("moving album {}", album_id) - - # Manually moving and storing the album. - items = list(album.items()) - for item in items: - item.move(store=False, with_album=False) - item.store(fields=item_fields) - album.move(store=False) - album.store(fields=album_fields) - - -def update_func(lib, opts, args): - # Verify that the library folder exists to prevent accidental wipes. - if not os.path.isdir(syspath(lib.directory)): - ui.print_("Library path is unavailable or does not exist.") - ui.print_(lib.directory) - if not ui.input_yn("Are you sure you want to continue (y/n)?", True): - return - update_items( - lib, - args, - opts.album, - ui.should_move(opts.move), - opts.pretend, - opts.fields, - opts.exclude_fields, - ) - - -update_cmd = ui.Subcommand( - "update", - help="update the library", - aliases=( - "upd", - "up", - ), -) -update_cmd.parser.add_album_option() -update_cmd.parser.add_format_option() -update_cmd.parser.add_option( - "-m", - "--move", - action="store_true", - dest="move", - help="move files in the library directory", -) -update_cmd.parser.add_option( - "-M", - "--nomove", - action="store_false", - dest="move", - help="don't move files in library", -) -update_cmd.parser.add_option( - "-p", - "--pretend", - action="store_true", - help="show all changes but do nothing", -) -update_cmd.parser.add_option( - "-F", - "--field", - default=None, - action="append", - dest="fields", - help="list of fields to update", -) -update_cmd.parser.add_option( - "-e", - "--exclude-field", - default=None, - action="append", - dest="exclude_fields", - help="list of fields to exclude from updates", -) -update_cmd.func = update_func -default_commands.append(update_cmd) - - -# remove: Remove items from library, delete files. - - -def remove_items(lib, query, album, delete, force): - """Remove items matching query from lib. If album, then match and - remove whole albums. If delete, also remove files from disk. - """ - # Get the matching items. - items, albums = _do_query(lib, query, album) - objs = albums if album else items - - # Confirm file removal if not forcing removal. - if not force: - # Prepare confirmation with user. - album_str = ( - f" in {len(albums)} album{'s' if len(albums) > 1 else ''}" - if album - else "" - ) - - if delete: - fmt = "$path - $title" - prompt = "Really DELETE" - prompt_all = ( - "Really DELETE" - f" {len(items)} file{'s' if len(items) > 1 else ''}{album_str}" - ) - else: - fmt = "" - prompt = "Really remove from the library?" - prompt_all = ( - "Really remove" - f" {len(items)} item{'s' if len(items) > 1 else ''}{album_str}" - " from the library?" - ) - - # Helpers for printing affected items - def fmt_track(t): - ui.print_(format(t, fmt)) - - def fmt_album(a): - ui.print_() - for i in a.items(): - fmt_track(i) - - fmt_obj = fmt_album if album else fmt_track - - # Show all the items. - for o in objs: - fmt_obj(o) - - # Confirm with user. - objs = ui.input_select_objects( - prompt, objs, fmt_obj, prompt_all=prompt_all - ) - - if not objs: - return - - # Remove (and possibly delete) items. - with lib.transaction(): - for obj in objs: - obj.remove(delete) - - -def remove_func(lib, opts, args): - remove_items(lib, args, opts.album, opts.delete, opts.force) - - -remove_cmd = ui.Subcommand( - "remove", help="remove matching items from the library", aliases=("rm",) -) -remove_cmd.parser.add_option( - "-d", "--delete", action="store_true", help="also remove files from disk" -) -remove_cmd.parser.add_option( - "-f", "--force", action="store_true", help="do not ask when removing items" -) -remove_cmd.parser.add_album_option() -remove_cmd.func = remove_func -default_commands.append(remove_cmd) - - -# stats: Show library/query statistics. - - -def show_stats(lib, query, exact): - """Shows some statistics about the matched items.""" - items = lib.items(query) - - total_size = 0 - total_time = 0.0 - total_items = 0 - artists = set() - albums = set() - album_artists = set() - - for item in items: - if exact: - try: - total_size += os.path.getsize(syspath(item.path)) - except OSError as exc: - log.info("could not get size of {.path}: {}", item, exc) - else: - total_size += int(item.length * item.bitrate / 8) - total_time += item.length - total_items += 1 - artists.add(item.artist) - album_artists.add(item.albumartist) - if item.album_id: - albums.add(item.album_id) - - size_str = human_bytes(total_size) - if exact: - size_str += f" ({total_size} bytes)" - - print_(f"""Tracks: {total_items} -Total time: {human_seconds(total_time)} -{f" ({total_time:.2f} seconds)" if exact else ""} -{"Total size" if exact else "Approximate total size"}: {size_str} -Artists: {len(artists)} -Albums: {len(albums)} -Album artists: {len(album_artists)}""") - - -def stats_func(lib, opts, args): - show_stats(lib, args, opts.exact) - - -stats_cmd = ui.Subcommand( - "stats", help="show statistics about the library or a query" -) -stats_cmd.parser.add_option( - "-e", "--exact", action="store_true", help="exact size and time" -) -stats_cmd.func = stats_func -default_commands.append(stats_cmd) - - -# version: Show current beets version. - - -def show_version(lib, opts, args): - print_(f"beets version {beets.__version__}") - print_(f"Python version {python_version()}") - # Show plugins. - names = sorted(p.name for p in plugins.find_plugins()) - if names: - print_("plugins:", ", ".join(names)) - else: - print_("no plugins loaded") - - -version_cmd = ui.Subcommand("version", help="output version information") -version_cmd.func = show_version -default_commands.append(version_cmd) - - -# modify: Declaratively change metadata. - - -def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): - """Modifies matching items according to user-specified assignments and - deletions. - - `mods` is a dictionary of field and value pairse indicating - assignments. `dels` is a list of fields to be deleted. - """ - # Parse key=value specifications into a dictionary. - model_cls = library.Album if album else library.Item - - # Get the items to modify. - items, albums = _do_query(lib, query, album, False) - objs = albums if album else items - - # Apply changes *temporarily*, preview them, and collect modified - # objects. - print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.") - changed = [] - templates = { - key: functemplate.template(value) for key, value in mods.items() - } - for obj in objs: - obj_mods = { - key: model_cls._parse(key, obj.evaluate_template(templates[key])) - for key in mods.keys() - } - if print_and_modify(obj, obj_mods, dels) and obj not in changed: - changed.append(obj) - - # Still something to do? - if not changed: - print_("No changes to make.") - return - - # Confirm action. - if confirm: - if write and move: - extra = ", move and write tags" - elif write: - extra = " and write tags" - elif move: - extra = " and move" - else: - extra = "" - - changed = ui.input_select_objects( - f"Really modify{extra}", - changed, - lambda o: print_and_modify(o, mods, dels), - ) - - # Apply changes to database and files - with lib.transaction(): - for obj in changed: - obj.try_sync(write, move, inherit) - - -def print_and_modify(obj, mods, dels): - """Print the modifications to an item and return a bool indicating - whether any changes were made. - - `mods` is a dictionary of fields and values to update on the object; - `dels` is a sequence of fields to delete. - """ - obj.update(mods) - for field in dels: - try: - del obj[field] - except KeyError: - pass - return ui.show_model_changes(obj) - - -def modify_parse_args(args): - """Split the arguments for the modify subcommand into query parts, - assignments (field=value), and deletions (field!). Returns the result as - a three-tuple in that order. - """ - mods = {} - dels = [] - query = [] - for arg in args: - if arg.endswith("!") and "=" not in arg and ":" not in arg: - dels.append(arg[:-1]) # Strip trailing !. - elif "=" in arg and ":" not in arg.split("=", 1)[0]: - key, val = arg.split("=", 1) - mods[key] = val - else: - query.append(arg) - return query, mods, dels - - -def modify_func(lib, opts, args): - query, mods, dels = modify_parse_args(args) - if not mods and not dels: - raise ui.UserError("no modifications specified") - modify_items( - lib, - mods, - dels, - query, - ui.should_write(opts.write), - ui.should_move(opts.move), - opts.album, - not opts.yes, - opts.inherit, - ) - - -modify_cmd = ui.Subcommand( - "modify", help="change metadata fields", aliases=("mod",) -) -modify_cmd.parser.add_option( - "-m", - "--move", - action="store_true", - dest="move", - help="move files in the library directory", -) -modify_cmd.parser.add_option( - "-M", - "--nomove", - action="store_false", - dest="move", - help="don't move files in library", -) -modify_cmd.parser.add_option( - "-w", - "--write", - action="store_true", - default=None, - help="write new metadata to files' tags (default)", -) -modify_cmd.parser.add_option( - "-W", - "--nowrite", - action="store_false", - dest="write", - help="don't write metadata (opposite of -w)", -) -modify_cmd.parser.add_album_option() -modify_cmd.parser.add_format_option(target="item") -modify_cmd.parser.add_option( - "-y", "--yes", action="store_true", help="skip confirmation" -) -modify_cmd.parser.add_option( - "-I", - "--noinherit", - action="store_false", - dest="inherit", - default=True, - help="when modifying albums, don't also change item data", -) -modify_cmd.func = modify_func -default_commands.append(modify_cmd) - - -# move: Move/copy files to the library or a new base directory. - - -def move_items( - lib, - dest_path: util.PathLike, - query, - copy, - album, - pretend, - confirm=False, - export=False, -): - """Moves or copies items to a new base directory, given by dest. If - dest is None, then the library's base directory is used, making the - command "consolidate" files. - """ - dest = os.fsencode(dest_path) if dest_path else dest_path - items, albums = _do_query(lib, query, album, False) - objs = albums if album else items - num_objs = len(objs) - - # Filter out files that don't need to be moved. - def isitemmoved(item): - return item.path != item.destination(basedir=dest) - - def isalbummoved(album): - return any(isitemmoved(i) for i in album.items()) - - objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)] - num_unmoved = num_objs - len(objs) - # Report unmoved files that match the query. - unmoved_msg = "" - if num_unmoved > 0: - unmoved_msg = f" ({num_unmoved} already in place)" - - copy = copy or export # Exporting always copies. - action = "Copying" if copy else "Moving" - act = "copy" if copy else "move" - entity = "album" if album else "item" - log.info( - "{} {} {}{}{}.", - action, - len(objs), - entity, - "s" if len(objs) != 1 else "", - unmoved_msg, - ) - if not objs: - return - - if pretend: - if album: - show_path_changes( - [ - (item.path, item.destination(basedir=dest)) - for obj in objs - for item in obj.items() - ] - ) - else: - show_path_changes( - [(obj.path, obj.destination(basedir=dest)) for obj in objs] - ) - else: - if confirm: - objs = ui.input_select_objects( - f"Really {act}", - objs, - lambda o: show_path_changes( - [(o.path, o.destination(basedir=dest))] - ), - ) - - for obj in objs: - log.debug("moving: {.filepath}", obj) - - if export: - # Copy without affecting the database. - obj.move( - operation=MoveOperation.COPY, basedir=dest, store=False - ) - else: - # Ordinary move/copy: store the new path. - if copy: - obj.move(operation=MoveOperation.COPY, basedir=dest) - else: - obj.move(operation=MoveOperation.MOVE, basedir=dest) - - -def move_func(lib, opts, args): - dest = opts.dest - if dest is not None: - dest = normpath(dest) - if not os.path.isdir(syspath(dest)): - raise ui.UserError(f"no such directory: {displayable_path(dest)}") - - move_items( - lib, - dest, - args, - opts.copy, - opts.album, - opts.pretend, - opts.timid, - opts.export, - ) - - -move_cmd = ui.Subcommand("move", help="move or copy items", aliases=("mv",)) -move_cmd.parser.add_option( - "-d", "--dest", metavar="DIR", dest="dest", help="destination directory" -) -move_cmd.parser.add_option( - "-c", - "--copy", - default=False, - action="store_true", - help="copy instead of moving", -) -move_cmd.parser.add_option( - "-p", - "--pretend", - default=False, - action="store_true", - help="show how files would be moved, but don't touch anything", -) -move_cmd.parser.add_option( - "-t", - "--timid", - dest="timid", - action="store_true", - help="always confirm all actions", -) -move_cmd.parser.add_option( - "-e", - "--export", - default=False, - action="store_true", - help="copy without changing the database path", -) -move_cmd.parser.add_album_option() -move_cmd.func = move_func -default_commands.append(move_cmd) - - -# write: Write tags into files. - - -def write_items(lib, query, pretend, force): - """Write tag information from the database to the respective files - in the filesystem. - """ - items, albums = _do_query(lib, query, False, False) - - for item in items: - # Item deleted? - if not os.path.exists(syspath(item.path)): - log.info("missing file: {.filepath}", item) - continue - - # Get an Item object reflecting the "clean" (on-disk) state. - try: - clean_item = library.Item.from_path(item.path) - except library.ReadError as exc: - log.error("error reading {.filepath}: {}", item, exc) - continue - - # Check for and display changes. - changed = ui.show_model_changes( - item, clean_item, library.Item._media_tag_fields, force - ) - if (changed or force) and not pretend: - # We use `try_sync` here to keep the mtime up to date in the - # database. - item.try_sync(True, False) - - -def write_func(lib, opts, args): - write_items(lib, args, opts.pretend, opts.force) - - -write_cmd = ui.Subcommand("write", help="write tag information to files") -write_cmd.parser.add_option( - "-p", - "--pretend", - action="store_true", - help="show all changes but do nothing", -) -write_cmd.parser.add_option( - "-f", - "--force", - action="store_true", - help="write tags even if the existing tags match the database", -) -write_cmd.func = write_func -default_commands.append(write_cmd) - - -# config: Show and edit user configuration. - - -def config_func(lib, opts, args): - # Make sure lazy configuration is loaded - config.resolve() - - # Print paths. - if opts.paths: - filenames = [] - for source in config.sources: - if not opts.defaults and source.default: - continue - if source.filename: - filenames.append(source.filename) - - # In case the user config file does not exist, prepend it to the - # list. - user_path = config.user_config_path() - if user_path not in filenames: - filenames.insert(0, user_path) - - for filename in filenames: - print_(displayable_path(filename)) - - # Open in editor. - elif opts.edit: - config_edit() - - # Dump configuration. - else: - config_out = config.dump(full=opts.defaults, redact=opts.redact) - if config_out.strip() != "{}": - print_(config_out) - else: - print("Empty configuration") - - -def config_edit(): - """Open a program to edit the user configuration. - An empty config file is created if no existing config file exists. - """ - path = config.user_config_path() - editor = util.editor_command() - try: - if not os.path.isfile(path): - open(path, "w+").close() - util.interactive_open([path], editor) - except OSError as exc: - message = f"Could not edit configuration: {exc}" - if not editor: - message += ( - ". Please set the VISUAL (or EDITOR) environment variable" - ) - raise ui.UserError(message) - - -config_cmd = ui.Subcommand("config", help="show or edit the user configuration") -config_cmd.parser.add_option( - "-p", - "--paths", - action="store_true", - help="show files that configuration was loaded from", -) -config_cmd.parser.add_option( - "-e", - "--edit", - action="store_true", - help="edit user configuration with $VISUAL (or $EDITOR)", -) -config_cmd.parser.add_option( - "-d", - "--defaults", - action="store_true", - help="include the default configuration", -) -config_cmd.parser.add_option( - "-c", - "--clear", - action="store_false", - dest="redact", - default=True, - help="do not redact sensitive fields", -) -config_cmd.func = config_func -default_commands.append(config_cmd) - - -# completion: print completion script - - -def print_completion(*args): - for line in completion_script(default_commands + plugins.commands()): - print_(line, end="") - if not any(os.path.isfile(syspath(p)) for p in BASH_COMPLETION_PATHS): - log.warning( - "Warning: Unable to find the bash-completion package. " - "Command line completion might not work." - ) - - -BASH_COMPLETION_PATHS = [ - b"/etc/bash_completion", - b"/usr/share/bash-completion/bash_completion", - b"/usr/local/share/bash-completion/bash_completion", - # SmartOS - b"/opt/local/share/bash-completion/bash_completion", - # Homebrew (before bash-completion2) - b"/usr/local/etc/bash_completion", -] - - -def completion_script(commands): - """Yield the full completion shell script as strings. - - ``commands`` is alist of ``ui.Subcommand`` instances to generate - completion data for. - """ - base_script = os.path.join(os.path.dirname(__file__), "completion_base.sh") - with open(base_script) as base_script: - yield base_script.read() - - options = {} - aliases = {} - command_names = [] - - # Collect subcommands - for cmd in commands: - name = cmd.name - command_names.append(name) - - for alias in cmd.aliases: - if re.match(r"^\w+$", alias): - aliases[alias] = name - - options[name] = {"flags": [], "opts": []} - for opts in cmd.parser._get_all_options()[1:]: - if opts.action in ("store_true", "store_false"): - option_type = "flags" - else: - option_type = "opts" - - options[name][option_type].extend( - opts._short_opts + opts._long_opts - ) - - # Add global options - options["_global"] = { - "flags": ["-v", "--verbose"], - "opts": "-l --library -c --config -d --directory -h --help".split(" "), - } - - # Add flags common to all commands - options["_common"] = {"flags": ["-h", "--help"]} - - # Start generating the script - yield "_beet() {\n" - - # Command names - yield f" local commands={' '.join(command_names)!r}\n" - yield "\n" - - # Command aliases - yield f" local aliases={' '.join(aliases.keys())!r}\n" - for alias, cmd in aliases.items(): - yield f" local alias__{alias.replace('-', '_')}={cmd}\n" - yield "\n" - - # Fields - fields = library.Item._fields.keys() | library.Album._fields.keys() - yield f" fields={' '.join(fields)!r}\n" - - # Command options - for cmd, opts in options.items(): - for option_type, option_list in opts.items(): - if option_list: - option_list = " ".join(option_list) - yield ( - " local" - f" {option_type}__{cmd.replace('-', '_')}='{option_list}'\n" - ) - - yield " _beet_dispatch\n" - yield "}\n" - - -completion_cmd = ui.Subcommand( - "completion", - help="print shell script that provides command line completion", -) -completion_cmd.func = print_completion -completion_cmd.hide = True -default_commands.append(completion_cmd) diff --git a/beets/ui/commands/__init__.py b/beets/ui/commands/__init__.py new file mode 100644 index 0000000000..d565bd5b46 --- /dev/null +++ b/beets/ui/commands/__init__.py @@ -0,0 +1,38 @@ +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# 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. + +"""This module provides the default commands for beets' command-line +interface. +""" + +from beets.util import deprecate_imports + +from .default_commands import default_commands + + +def __getattr__(name: str): + """Handle deprecated imports.""" + return deprecate_imports( + old_module=__name__, + new_module_by_name={ + "TerminalImportSession": "beets.ui.commands.import_.session", + "PromptChoice": "beets.ui.commands.import_.session", + # TODO: We might want to add more deprecated imports here + }, + name=name, + version="3.0.0", + ) + + +__all__ = ["default_commands"] diff --git a/beets/ui/commands/_utils.py b/beets/ui/commands/_utils.py new file mode 100644 index 0000000000..1d873f0707 --- /dev/null +++ b/beets/ui/commands/_utils.py @@ -0,0 +1,81 @@ +"""Utility functions for beets UI commands.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from beets.ui._common import UserError +from beets.util import PathLike, displayable_path, normpath, syspath + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + + from _typeshed import FileDescriptorOrPath + + +def do_query(lib, query, album, also_items=True): + """For commands that operate on matched items, performs a query + and returns a list of matching items and a list of matching + albums. (The latter is only nonempty when album is True.) Raises + a UserError if no items match. also_items controls whether, when + fetching albums, the associated items should be fetched also. + """ + if album: + albums = list(lib.albums(query)) + items = [] + if also_items: + for al in albums: + items += al.items() + + else: + albums = [] + items = list(lib.items(query)) + + if album and not albums: + raise UserError("No matching albums found.") + elif not album and not items: + raise UserError("No matching items found.") + + return items, albums + + +def paths_from_logfile(path: FileDescriptorOrPath) -> Iterator[str]: + """Parse the logfile and yield skipped paths to pass to the `import` + command. + """ + with open(path, encoding="utf-8") as fp: + i: int + line: str + for i, line in enumerate(fp, start=1): + verb: str + sep: str + paths: str + verb, sep, paths = line.rstrip("\n").partition(" ") + if not sep: + raise ValueError(f"line {i} is invalid") + + # Ignore informational lines that don't need to be re-imported. + if verb in {"import", "duplicate-keep", "duplicate-replace"}: + continue + + if verb not in {"asis", "skip", "duplicate-skip"}: + raise ValueError(f"line {i} contains unknown verb {verb}") + + yield os.path.commonpath(paths.split("; ")) + + +def parse_logfiles(logfiles: Iterable[PathLike]) -> Iterator[str]: + """Parse all `logfiles` and yield paths from it.""" + logfile: PathLike + for logfile in logfiles: + try: + yield from paths_from_logfile(syspath(normpath(logfile))) + except ValueError as err: + raise UserError( + f"malformed logfile {displayable_path(logfile)}: {err}" + ) from err + except OSError as err: + raise UserError( + f"unreadable logfile {displayable_path(logfile)}: {err}" + ) from err diff --git a/beets/ui/commands/completion.py b/beets/ui/commands/completion.py new file mode 100644 index 0000000000..f36550abfc --- /dev/null +++ b/beets/ui/commands/completion.py @@ -0,0 +1,118 @@ +"""The 'completion' command: print shell script for command line completion.""" + +import os +import re + +from beets import library, logging, plugins +from beets.ui.core import Subcommand, print_ +from beets.util import syspath + +# Global logger. +log = logging.getLogger("beets") + + +def print_completion(*args): + from beets.ui.commands import default_commands + + for line in completion_script(default_commands + plugins.commands()): + print_(line, end="") + if not any(os.path.isfile(syspath(p)) for p in BASH_COMPLETION_PATHS): + log.warning( + "Warning: Unable to find the bash-completion package. " + "Command line completion might not work." + ) + + +completion_cmd = Subcommand( + "completion", + help="print shell script that provides command line completion", +) +completion_cmd.func = print_completion +completion_cmd.hide = True + + +BASH_COMPLETION_PATHS = [ + b"/etc/bash_completion", + b"/usr/share/bash-completion/bash_completion", + b"/usr/local/share/bash-completion/bash_completion", + # SmartOS + b"/opt/local/share/bash-completion/bash_completion", + # Homebrew (before bash-completion2) + b"/usr/local/etc/bash_completion", +] + + +def completion_script(commands): + """Yield the full completion shell script as strings. + + ``commands`` is alist of ``ui.Subcommand`` instances to generate + completion data for. + """ + base_script = os.path.join( + os.path.dirname(__file__), "../completion_base.sh" + ) + with open(base_script) as base_script: + yield base_script.read() + + options = {} + aliases = {} + command_names = [] + + # Collect subcommands + for cmd in commands: + name = cmd.name + command_names.append(name) + + for alias in cmd.aliases: + if re.match(r"^\w+$", alias): + aliases[alias] = name + + options[name] = {"flags": [], "opts": []} + for opts in cmd.parser._get_all_options()[1:]: + if opts.action in ("store_true", "store_false"): + option_type = "flags" + else: + option_type = "opts" + + options[name][option_type].extend( + opts._short_opts + opts._long_opts + ) + + # Add global options + options["_global"] = { + "flags": ["-v", "--verbose"], + "opts": "-l --library -c --config -d --directory -h --help".split(" "), + } + + # Add flags common to all commands + options["_common"] = {"flags": ["-h", "--help"]} + + # Start generating the script + yield "_beet() {\n" + + # Command names + yield f" local commands={' '.join(command_names)!r}\n" + yield "\n" + + # Command aliases + yield f" local aliases={' '.join(aliases.keys())!r}\n" + for alias, cmd in aliases.items(): + yield f" local alias__{alias.replace('-', '_')}={cmd}\n" + yield "\n" + + # Fields + fields = library.Item._fields.keys() | library.Album._fields.keys() + yield f" fields={' '.join(fields)!r}\n" + + # Command options + for cmd, opts in options.items(): + for option_type, option_list in opts.items(): + if option_list: + option_list = " ".join(option_list) + yield ( + " local" + f" {option_type}__{cmd.replace('-', '_')}='{option_list}'\n" + ) + + yield " _beet_dispatch\n" + yield "}\n" diff --git a/beets/ui/commands/config.py b/beets/ui/commands/config.py new file mode 100644 index 0000000000..c25a05fb8a --- /dev/null +++ b/beets/ui/commands/config.py @@ -0,0 +1,91 @@ +"""The 'config' command: show and edit user configuration.""" + +import os + +from beets import config, util +from beets.ui._common import UserError +from beets.ui.core import Subcommand, print_ + + +def config_func(lib, opts, args): + # Make sure lazy configuration is loaded + config.resolve() + + # Print paths. + if opts.paths: + filenames = [] + for source in config.sources: + if not opts.defaults and source.default: + continue + if source.filename: + filenames.append(source.filename) + + # In case the user config file does not exist, prepend it to the + # list. + user_path = config.user_config_path() + if user_path not in filenames: + filenames.insert(0, user_path) + + for filename in filenames: + print_(util.displayable_path(filename)) + + # Open in editor. + elif opts.edit: + config_edit() + + # Dump configuration. + else: + config_out = config.dump(full=opts.defaults, redact=opts.redact) + if config_out.strip() != "{}": + print_(config_out) + else: + print("Empty configuration") + + +def config_edit(): + """Open a program to edit the user configuration. + An empty config file is created if no existing config file exists. + """ + path = config.user_config_path() + editor = util.editor_command() + try: + if not os.path.isfile(path): + open(path, "w+").close() + util.interactive_open([path], editor) + except OSError as exc: + message = f"Could not edit configuration: {exc}" + if not editor: + message += ( + ". Please set the VISUAL (or EDITOR) environment variable" + ) + raise UserError(message) + + +config_cmd = Subcommand("config", help="show or edit the user configuration") +config_cmd.parser.add_option( + "-p", + "--paths", + action="store_true", + help="show files that configuration was loaded from", +) +config_cmd.parser.add_option( + "-e", + "--edit", + action="store_true", + help="edit user configuration with $VISUAL (or $EDITOR)", +) +config_cmd.parser.add_option( + "-d", + "--defaults", + action="store_true", + help="include the default configuration", +) +config_cmd.parser.add_option( + "-c", + "--clear", + action="store_false", + dest="redact", + default=True, + help="do not redact sensitive fields", +) +config_cmd.func = config_func diff --git a/beets/ui/commands/default_commands.py b/beets/ui/commands/default_commands.py new file mode 100644 index 0000000000..939cc6b474 --- /dev/null +++ b/beets/ui/commands/default_commands.py @@ -0,0 +1,31 @@ +from .completion import completion_cmd +from .config import config_cmd +from .fields import fields_cmd +from .help import HelpCommand +from .import_ import import_cmd +from .list import list_cmd +from .modify import modify_cmd +from .move import move_cmd +from .remove import remove_cmd +from .stats import stats_cmd +from .update import update_cmd +from .version import version_cmd +from .write import write_cmd + +# The list of default subcommands. This is populated with Subcommand +# objects that can be fed to a SubcommandsOptionParser. +default_commands = [ + fields_cmd, + HelpCommand(), + import_cmd, + list_cmd, + update_cmd, + remove_cmd, + stats_cmd, + version_cmd, + modify_cmd, + move_cmd, + write_cmd, + config_cmd, + completion_cmd, +] diff --git a/beets/ui/commands/fields.py b/beets/ui/commands/fields.py new file mode 100644 index 0000000000..b30790d3b6 --- /dev/null +++ b/beets/ui/commands/fields.py @@ -0,0 +1,42 @@ +"""The `fields` command: show available fields for queries and format strings.""" + +import textwrap + +from beets import library +from beets.ui.core import Subcommand, print_ + + +def _print_keys(query): + """Given a SQLite query result, print the `key` field of each + returned row, with indentation of 2 spaces. + """ + for row in query: + print_(f" {row['key']}") + + +def fields_func(lib, opts, args): + def _print_rows(names): + names.sort() + print_(textwrap.indent("\n".join(names), " ")) + + print_("Item fields:") + _print_rows(library.Item.all_keys()) + + print_("Album fields:") + _print_rows(library.Album.all_keys()) + + with lib.transaction() as tx: + # The SQL uses the DISTINCT to get unique values from the query + unique_fields = "SELECT DISTINCT key FROM ({})" + + print_("Item flexible attributes:") + _print_keys(tx.query(unique_fields.format(library.Item._flex_table))) + + print_("Album flexible attributes:") + _print_keys(tx.query(unique_fields.format(library.Album._flex_table))) + + +fields_cmd = Subcommand( + "fields", help="show fields available for queries and format strings" +) +fields_cmd.func = fields_func diff --git a/beets/ui/commands/help.py b/beets/ui/commands/help.py new file mode 100644 index 0000000000..94c321435b --- /dev/null +++ b/beets/ui/commands/help.py @@ -0,0 +1,23 @@ +"""The 'help' command: show help information for commands.""" + +from beets.ui._common import UserError +from beets.ui.core import Subcommand + + +class HelpCommand(Subcommand): + def __init__(self): + super().__init__( + "help", + aliases=("?",), + help="give detailed help on a specific sub-command", + ) + + def func(self, lib, opts, args): + if args: + cmdname = args[0] + helpcommand = self.root_parser._subcommand_for_name(cmdname) + if not helpcommand: + raise UserError(f"unknown command '{cmdname}'") + helpcommand.print_help() + else: + self.root_parser.print_help() diff --git a/beets/ui/commands/import_/__init__.py b/beets/ui/commands/import_/__init__.py new file mode 100644 index 0000000000..8b6a237e65 --- /dev/null +++ b/beets/ui/commands/import_/__init__.py @@ -0,0 +1,312 @@ +"""The `import` command: import new music into the library.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from beets import config, logging, plugins +from beets.ui._common import UserError +from beets.ui.core import Subcommand, _store_dict +from beets.util import displayable_path, normpath, syspath + +from .._utils import parse_logfiles +from .session import TerminalImportSession + +if TYPE_CHECKING: + from logging import FileHandler + from optparse import Values + + from beets.dbcore import Query + from beets.library import Library + + +# Global logger. +log = logging.getLogger("beets") + + +def import_files( + lib: Library, + paths: list[bytes], + query: Query | str | list[str] | tuple[str] | None, +) -> None: + """Import the files in the given list of paths or matching the + query. + """ + # Check parameter consistency. + if config["import"]["quiet"] and config["import"]["timid"]: + raise UserError("can't be both quiet and timid") + + # Open the log. + if config["import"]["log"].get() is not None: + logpath: str = syspath(config["import"]["log"].as_filename()) + loghandler: FileHandler | None + try: + loghandler = logging.FileHandler(logpath, encoding="utf-8") + except OSError: + raise UserError( + "Could not open log file for writing:" + f" {displayable_path(logpath)}" + ) + else: + loghandler = None + + # Never ask for input in quiet mode. + if config["import"]["resume"].get() == "ask" and config["import"]["quiet"]: + config["import"]["resume"] = False + + session: TerminalImportSession = TerminalImportSession( + lib, loghandler, paths, query + ) + session.run() + + # Emit event. + _ = plugins.send("import", lib=lib, paths=paths) + + +def import_func( + lib: Library, + opts: Values, + args: list[str] | tuple[str], +) -> None: + config["import"].set_args(opts) + + # Special case: --copy flag suppresses import_move (which would + # otherwise take precedence). + if opts.copy: + config["import"]["move"] = False + + query: Query | str | list[str] | tuple[str] | None + byte_paths: list[bytes] + if opts.library: + query = args + byte_paths = [] + else: + query = None + paths: list[str] | tuple[str] = args + + # The paths from the logfiles go into a separate list to allow handling + # errors differently from user-specified paths. + paths_from_logfiles: list[str] = list( + parse_logfiles(opts.from_logfiles or []) + ) + + if not paths and not paths_from_logfiles: + raise UserError("no path specified") + + byte_paths = [os.fsencode(p) for p in paths] + byte_paths_from_logfiles: list[bytes] = [ + os.fsencode(p) for p in paths_from_logfiles + ] + + # Check the user-specified directories. + path: bytes + for path in byte_paths: + if not os.path.exists(syspath(normpath(path))): + raise UserError( + f"no such file or directory: {displayable_path(path)}" + ) + + # Check the directories from the logfiles, but don't throw an error in + # case those paths don't exist. Maybe some of those paths have already + # been imported and moved separately, so logging a warning should + # suffice. + for path in byte_paths_from_logfiles: + if not os.path.exists(syspath(normpath(path))): + log.warning( + "No such file or directory: {}", displayable_path(path) + ) + continue + + byte_paths.append(path) + + # If all paths were read from a logfile, and none of them exist, throw + # an error + if not paths: + raise UserError("none of the paths are importable") + + import_files(lib, byte_paths, query) + + +import_cmd = Subcommand( + "import", help="import new music", aliases=("imp", "im") +) +_ = import_cmd.parser.add_option( + "-c", + "--copy", + action="store_true", + default=None, + help="copy tracks into library directory (default)", +) +_ = import_cmd.parser.add_option( + "-C", + "--nocopy", + action="store_false", + dest="copy", + help="don't copy tracks (opposite of -c)", +) +_ = import_cmd.parser.add_option( + "-m", + "--move", + action="store_true", + dest="move", + help="move tracks into the library (overrides -c)", +) +_ = import_cmd.parser.add_option( + "-w", + "--write", + action="store_true", + default=None, + help="write new metadata to files' tags (default)", +) +_ = import_cmd.parser.add_option( + "-W", + "--nowrite", + action="store_false", + dest="write", + help="don't write metadata (opposite of -w)", +) +_ = import_cmd.parser.add_option( + "-a", + "--autotag", + action="store_true", + dest="autotag", + help="infer tags for imported files (default)", +) +_ = import_cmd.parser.add_option( + "-A", + "--noautotag", + action="store_false", + dest="autotag", + help="don't infer tags for imported files (opposite of -a)", +) +_ = import_cmd.parser.add_option( + "-p", + "--resume", + action="store_true", + default=None, + help="resume importing if interrupted", +) +_ = import_cmd.parser.add_option( + "-P", + "--noresume", + action="store_false", + dest="resume", + help="do not try to resume importing", +) +_ = import_cmd.parser.add_option( + "-q", + "--quiet", + action="store_true", + dest="quiet", + help="never prompt for input: skip albums instead", +) +_ = import_cmd.parser.add_option( + "--quiet-fallback", + type="string", + dest="quiet_fallback", + help="decision in quiet mode when no strong match: skip or asis", +) +_ = import_cmd.parser.add_option( + "-l", + "--log", + dest="log", + help="file to log untaggable albums for later review", +) +_ = import_cmd.parser.add_option( + "-s", + "--singletons", + action="store_true", + help="import individual tracks instead of full albums", +) +_ = import_cmd.parser.add_option( + "-t", + "--timid", + dest="timid", + action="store_true", + help="always confirm all actions", +) +_ = import_cmd.parser.add_option( + "-L", + "--library", + dest="library", + action="store_true", + help="retag items matching a query", +) +_ = import_cmd.parser.add_option( + "-i", + "--incremental", + dest="incremental", + action="store_true", + help="skip already-imported directories", +) +_ = import_cmd.parser.add_option( + "-I", + "--noincremental", + dest="incremental", + action="store_false", + help="do not skip already-imported directories", +) +_ = import_cmd.parser.add_option( + "-R", + "--incremental-skip-later", + action="store_true", + dest="incremental_skip_later", + help="do not record skipped files during incremental import", +) +_ = import_cmd.parser.add_option( + "-r", + "--noincremental-skip-later", + action="store_false", + dest="incremental_skip_later", + help="record skipped files during incremental import", +) +_ = import_cmd.parser.add_option( + "--from-scratch", + dest="from_scratch", + action="store_true", + help="erase existing metadata before applying new metadata", +) +_ = import_cmd.parser.add_option( + "--flat", + dest="flat", + action="store_true", + help="import an entire tree as a single album", +) +_ = import_cmd.parser.add_option( + "-g", + "--group-albums", + dest="group_albums", + action="store_true", + help="group tracks in a folder into separate albums", +) +_ = import_cmd.parser.add_option( + "--pretend", + dest="pretend", + action="store_true", + help="just print the files to import", +) +_ = import_cmd.parser.add_option( + "-S", + "--search-id", + dest="search_ids", + action="append", + metavar="ID", + help="restrict matching to a specific metadata backend ID", +) +_ = import_cmd.parser.add_option( + "--from-logfile", + dest="from_logfiles", + action="append", + metavar="PATH", + help="read skipped paths from an existing logfile", +) +_ = import_cmd.parser.add_option( + "--set", + dest="set_fields", + action="callback", + callback=_store_dict, + metavar="FIELD=VALUE", + help="set the given fields to the supplied values", +) +import_cmd.func = import_func diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py new file mode 100644 index 0000000000..12cd6af4eb --- /dev/null +++ b/beets/ui/commands/import_/display.py @@ -0,0 +1,679 @@ +from __future__ import annotations + +import os +from functools import cached_property +from typing import TYPE_CHECKING, Generic, TypeVar, overload + +from beets import config, logging +from beets.autotag import AlbumMatch, TrackMatch, hooks +from beets.ui.colors import ColorName, colordiff, colorize, uncolorize +from beets.ui.core import ( + indent, + print_, + print_column_layout, + print_newline_layout, + term_width, +) +from beets.util import displayable_path +from beets.util.units import human_seconds_short + +if TYPE_CHECKING: + from collections.abc import MutableSequence, Sequence + + from beets.autotag.distance import Distance + from beets.library import Item + from beets.ui.core import ColumnLayout + +M = TypeVar("M", AlbumMatch, TrackMatch) + +VARIOUS_ARTISTS = "Various Artists" + +# Global logger. +log: logging.BeetsLogger = logging.getLogger("beets") + + +class ChangeRepresentation(Generic[M]): + """Keeps track of all information needed to generate a (colored) text + representation of the changes that will be made if an album or singleton's + tags are changed according to `match`, which must be an AlbumMatch or + TrackMatch object, accordingly. + """ + + @cached_property + def changed_prefix(self) -> str: + return colorize("changed", "\u2260") + + @overload + def __init__( + self, + cur_artist: str | None, + match: AlbumMatch, + cur_album: str | None, + /, + ) -> None: ... + + @overload + def __init__( + self, + cur_artist: str | None, + match: TrackMatch, + cur_title: str | None, + /, + ) -> None: ... + + def __init__( + self, + cur_artist: str | None, + match: M, + cur_album: str | None = None, + cur_title: str | None = None, + /, + ) -> None: + self.cur_artist: str | None = cur_artist + self.match: M = match + self.cur_album: str | None = cur_album + self.cur_title: str | None = cur_title + # Read match header indentation width from config. + match_header_indent_width: int = config["ui"]["import"]["indentation"][ + "match_header" + ].as_number() + self.indent_header: str = indent(match_header_indent_width) + + # Read match detail indentation width from config. + match_detail_indent_width: int = config["ui"]["import"]["indentation"][ + "match_details" + ].as_number() + self.indent_detail: str = indent(match_detail_indent_width) + + # Read match tracklist indentation width from config + match_tracklist_indent_width: int = config["ui"]["import"][ + "indentation" + ]["match_tracklist"].as_number() + self.indent_tracklist: str = indent(match_tracklist_indent_width) + self.layout: int = config["ui"]["import"]["layout"].as_choice( + { + "column": 0, + "newline": 1, + } + ) + + def print_layout( + self, + indent: str, + left: ColumnLayout, + right: ColumnLayout, + separator: str = " -> ", + max_width: int | None = None, + ) -> None: + if not max_width: + # If no max_width provided, use terminal width + max_width = term_width() + if self.layout == 0: + print_column_layout(indent, left, right, separator, max_width) + else: + print_newline_layout(indent, left, right, separator, max_width) + + def show_match_header(self) -> None: + """Print out a 'header' identifying the suggested match (album name, + artist name,...) and summarizing the changes that would be made should + the user accept the match. + """ + # Print newline at beginning of change block. + print_("") + + # 'Match' line and similarity. + print_( + f"{self.indent_header}Match ({dist_string(self.match.distance)}):" + ) + + artist_album_str: str + if isinstance(self.match, AlbumMatch): + # Matching an album - print that + artist_album_str = ( + f"{self.match.info.artist} - {self.match.info.album}" + ) + else: + # Matching a single track + artist_album_str = ( + f"{self.match.info.artist} - {self.match.info.title}" + ) + print_( + self.indent_header + + dist_colorize(artist_album_str, self.match.distance) + ) + + # Penalties. + penalties: str | None = penalty_string(self.match.distance) + if penalties: + print_(f"{self.indent_header}{penalties}") + + # Disambiguation. + disambig: str = disambig_string(self.match.info) + if disambig: + print_(f"{self.indent_header}{disambig}") + + # Data URL. + if self.match.info.data_url: + url: str = colorize("text_faint", f"{self.match.info.data_url}") + print_(f"{self.indent_header}{url}") + + def show_match_details(self) -> None: + """Print out the details of the match, including changes in album name + and artist name. + """ + # Artist. + artist_l: str + artist_r: str + artist_l, artist_r = self.cur_artist or "", self.match.info.artist or "" + if artist_r == VARIOUS_ARTISTS: + # Hide artists for VA releases. + artist_l, artist_r = "", "" + left: ColumnLayout + right: ColumnLayout + if artist_l != artist_r: + artist_l, artist_r = colordiff(artist_l, artist_r) + left = { + "prefix": f"{self.changed_prefix} Artist: ", + "contents": artist_l, + "suffix": "", + } + right = {"prefix": "", "contents": artist_r, "suffix": ""} + self.print_layout(self.indent_detail, left, right) + + else: + print_(f"{self.indent_detail}*", "Artist:", artist_r) + + if self.cur_album: + # Album + album_l: str + album_r: str + album_l, album_r = self.cur_album or "", self.match.info.album or "" + if ( + self.cur_album != self.match.info.album + and self.match.info.album != VARIOUS_ARTISTS + ): + album_l, album_r = colordiff(album_l, album_r) + left = { + "prefix": f"{self.changed_prefix} Album: ", + "contents": album_l, + "suffix": "", + } + right = {"prefix": "", "contents": album_r, "suffix": ""} + self.print_layout(self.indent_detail, left, right) + else: + print_(f"{self.indent_detail}*", "Album:", album_r) + elif self.cur_title: + # Title - for singletons + title_l: str + title_r: str + title_l, title_r = self.cur_title or "", self.match.info.title or "" + if self.cur_title != self.match.info.title: + title_l, title_r = colordiff(title_l, title_r) + left = { + "prefix": f"{self.changed_prefix} Title: ", + "contents": title_l, + "suffix": "", + } + right = {"prefix": "", "contents": title_r, "suffix": ""} + self.print_layout(self.indent_detail, left, right) + else: + print_(f"{self.indent_detail}*", "Title:", title_r) + + def make_medium_info_line(self, track_info: hooks.TrackInfo) -> str: + """Construct a line with the current medium's info.""" + track_media: str = track_info.get("media", "Media") + # Build output string. + mediums: int | None + if mediums := self.match.info.mediums: + if mediums > 1 and track_info.disctitle: + return f"* {track_media} {track_info.medium}: {track_info.disctitle}" + elif mediums > 1: + return f"* {track_media} {track_info.medium}" + + if track_info.disctitle: + return f"* {track_media}: {track_info.disctitle}" + else: + return "" + + def format_index(self, track_info: hooks.TrackInfo | Item) -> str: + """Return a string representing the track index of the given + hooks.TrackInfo or Item object. + """ + index: int | None + medium_index: int | None + medium: int | None + mediums: int | None + if isinstance(track_info, hooks.TrackInfo): + index = track_info.index + medium_index = track_info.medium_index + medium = track_info.medium + mediums = self.match.info.mediums + else: + index = medium_index = track_info.track + medium = track_info.disc + mediums = track_info.disctotal + if config["per_disc_numbering"]: + if mediums and mediums > 1: + return f"{medium}-{medium_index}" + else: + return str(medium_index if medium_index is not None else index) + else: + return str(index) + + def make_track_numbers( + self, item: Item, track_info: hooks.TrackInfo + ) -> tuple[str, str, bool]: + """Format colored track indices.""" + cur_track: str = self.format_index(item) + new_track: str = self.format_index(track_info) + changed: bool = False + # Choose color based on change. + highlight_color: ColorName + if cur_track != new_track: + changed = True + if item.track in (track_info.index, track_info.medium_index): + highlight_color = "text_highlight_minor" + else: + highlight_color = "text_highlight" + else: + highlight_color = "text_faint" + + lhs_track: str = colorize(highlight_color, f"(#{cur_track})") + rhs_track: str = colorize(highlight_color, f"(#{new_track})") + return lhs_track, rhs_track, changed + + @staticmethod + def make_track_titles( + item: Item, track_info: hooks.TrackInfo + ) -> tuple[str, str, bool]: + """Format colored track titles.""" + new_title: str = track_info.title or "" + cur_title: str + if not item.title.strip(): + # If there's no title, we use the filename. Don't colordiff. + cur_title = displayable_path(os.path.basename(item.path)) + return cur_title, new_title, True + else: + # If there is a title, highlight differences. + cur_title = item.title.strip() + cur_col: str + new_col: str + cur_col, new_col = colordiff(cur_title, new_title) + return cur_col, new_col, cur_title != new_title + + @staticmethod + def make_track_lengths( + item: Item, track_info: hooks.TrackInfo + ) -> tuple[str, str, bool]: + """Format colored track lengths.""" + changed: bool = False + highlight_color: ColorName + if ( + item.length + and track_info.length + and abs(item.length - track_info.length) + >= config["ui"]["length_diff_thresh"].as_number() + ): + highlight_color = "text_highlight" + changed = True + else: + highlight_color = "text_highlight_minor" + + # Handle nonetype lengths by setting to 0 + cur_length0: float = item.length if item.length else 0 + new_length0: float = track_info.length if track_info.length else 0 + # format into string + cur_length: str = f"({human_seconds_short(cur_length0)})" + new_length: str = f"({human_seconds_short(new_length0)})" + # colorize + lhs_length: str = colorize(highlight_color, cur_length) + rhs_length: str = colorize(highlight_color, new_length) + + return lhs_length, rhs_length, changed + + def make_line( + self, item: Item, track_info: hooks.TrackInfo + ) -> tuple[ColumnLayout, ColumnLayout]: + """Extract changes from item -> new TrackInfo object, and colorize + appropriately. Returns (lhs, rhs) for column printing. + """ + # Track titles. + lhs_title: str + rhs_title: str + diff_title: bool + lhs_title, rhs_title, diff_title = self.make_track_titles( + item, track_info + ) + # Track number change. + lhs_track: str + rhs_track: str + diff_track: bool + lhs_track, rhs_track, diff_track = self.make_track_numbers( + item, track_info + ) + # Length change. + lhs_length: str + rhs_length: str + diff_length: bool + lhs_length, rhs_length, diff_length = self.make_track_lengths( + item, track_info + ) + + changed: bool = diff_title or diff_track or diff_length + + # Construct lhs and rhs dicts. + # Previously, we printed the penalties, however this is no longer + # the case, thus the 'info' dictionary is unneeded. + # penalties = penalty_string(self.match.distance.tracks[track_info]) + + lhs: ColumnLayout = { + "prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ", + "contents": lhs_title, + "suffix": f" {lhs_length}", + } + rhs: ColumnLayout = {"prefix": "", "contents": "", "suffix": ""} + if not changed: + # Only return the left side, as nothing changed. + return (lhs, rhs) + else: + # Construct a dictionary for the "changed to" side + rhs = { + "prefix": f"{rhs_track} ", + "contents": rhs_title, + "suffix": f" {rhs_length}", + } + return (lhs, rhs) + + def print_tracklist( + self, lines: Sequence[tuple[ColumnLayout, ColumnLayout]] + ) -> None: + """Calculates column widths for tracks stored as line tuples: + (left, right). Then prints each line of tracklist. + """ + if len(lines) == 0: + # If no lines provided, e.g. details not required, do nothing. + return + + def get_width(side: ColumnLayout) -> int: + """Return the width of left or right in uncolorized characters.""" + try: + return len( + uncolorize( + " ".join( + [side["prefix"], side["contents"], side["suffix"]] + ) + ) + ) + except KeyError: + # An empty dictionary -> Nothing to report + return 0 + + # Check how to fit content into terminal window + indent_width: int = len(self.indent_tracklist) + terminal_width: int = term_width() + joiner_width: int = len("".join(["* ", " -> "])) + col_width: int = (terminal_width - indent_width - joiner_width) // 2 + max_width_l: int = max(get_width(line_tuple[0]) for line_tuple in lines) + max_width_r: int = max(get_width(line_tuple[1]) for line_tuple in lines) + + col_width_l: int + col_width_r: int + if ( + (max_width_l <= col_width) + and (max_width_r <= col_width) + or ( + ((max_width_l > col_width) or (max_width_r > col_width)) + and ((max_width_l + max_width_r) <= col_width * 2) + ) + ): + # All content fits. Either both maximum widths are below column + # widths, or one of the columns is larger than allowed but the + # other is smaller than allowed. + # In this case we can afford to shrink the columns to fit their + # largest string + col_width_l = max_width_l + col_width_r = max_width_r + else: + # Not all content fits - stick with original half/half split + col_width_l = col_width + col_width_r = col_width + + # Print out each line, using the calculated width from above. + left: ColumnLayout + right: ColumnLayout + for left, right in lines: + left["width"] = col_width_l + right["width"] = col_width_r + self.print_layout(self.indent_tracklist, left, right) + + +class AlbumChange(ChangeRepresentation[AlbumMatch]): + """Album change representation, setting cur_album""" + + def __init__( + self, cur_artist: str | None, cur_album: str | None, match: AlbumMatch + ) -> None: + super().__init__(cur_artist, match, cur_album) + + def show_match_tracks(self) -> None: + """Print out the tracks of the match, summarizing changes the match + suggests for them. + """ + # Tracks. + # match is an AlbumMatch NamedTuple, mapping is a dict + # Sort the pairs by the track_info index (at index 1 of the NamedTuple) + pairs: list[tuple[Item, hooks.TrackInfo]] = list( + self.match.mapping.items() + ) + pairs.sort( + key=lambda item_and_track_info: item_and_track_info[1].index or 0 + ) + # Build up LHS and RHS for track difference display. The `lines` list + # contains `(left, right)` tuples. + lines: MutableSequence[tuple[ColumnLayout, ColumnLayout]] = [] + medium: int | None = None + disctitle: str | None = None + item: Item + track_info: hooks.TrackInfo + for item, track_info in pairs: + # If the track is the first on a new medium, show medium + # number and title. + if medium != track_info.medium or disctitle != track_info.disctitle: + # Create header for new medium + header: str = self.make_medium_info_line(track_info) + if header != "": + # Print tracks from previous medium + self.print_tracklist(lines) + lines = [] + print_(f"{self.indent_detail}{header}") + # Save new medium details for future comparison. + medium, disctitle = track_info.medium, track_info.disctitle + + # Construct the line tuple for the track. + left: ColumnLayout + right: ColumnLayout + left, right = self.make_line(item, track_info) + if right["contents"] != "": + lines.append((left, right)) + else: + if config["import"]["detail"]: + lines.append((left, right)) + self.print_tracklist(lines) + + # Missing and unmatched tracks. + if self.match.extra_tracks: + print_( + "Missing tracks" + f" ({len(self.match.extra_tracks)}/{len(self.match.info.tracks)} -" + f" {len(self.match.extra_tracks) / len(self.match.info.tracks):.1%}):" + ) + line: str + for track_info in self.match.extra_tracks: + line = f" ! {track_info.title} (#{self.format_index(track_info)})" + if track_info.length: + line += f" ({human_seconds_short(track_info.length)})" + print_(colorize("text_warning", line)) + if self.match.extra_items: + print_(f"Unmatched tracks ({len(self.match.extra_items)}):") + for item in self.match.extra_items: + line = f" ! {item.title} (#{self.format_index(item)})" + if item.length: + line += f" ({human_seconds_short(item.length)})" + print_(colorize("text_warning", line)) + + +class TrackChange(ChangeRepresentation[TrackMatch]): + """Track change representation, comparing item with match.""" + + def __init__( + self, cur_artist: str | None, cur_title: str | None, match: TrackMatch + ) -> None: + super().__init__(cur_artist, match, cur_title) + + +def show_change( + cur_artist: str | None, cur_album: str | None, match: AlbumMatch +) -> None: + """Print out a representation of the changes that will be made if an + album's tags are changed according to `match`, which must be an AlbumMatch + object. + """ + change: AlbumChange = AlbumChange( + cur_artist=cur_artist, cur_album=cur_album, match=match + ) + + # Print the match header. + change.show_match_header() + + # Print the match details. + change.show_match_details() + + # Print the match tracks. + change.show_match_tracks() + + +def show_item_change(item: Item | None, match: TrackMatch) -> None: + """Print out the change that would occur by tagging `item` with the + metadata from `match`, a TrackMatch object. + """ + artist: str | None + title: str | None + artist, title = (item.artist, item.title) if item else (None, None) + change: TrackChange = TrackChange( + cur_artist=artist, cur_title=title, match=match + ) + # Print the match header. + change.show_match_header() + # Print the match details. + change.show_match_details() + + +def disambig_string(info: hooks.AlbumInfo | hooks.TrackInfo) -> str: + """Generate a string for an AlbumInfo or TrackInfo object that + provides context that helps disambiguate similar-looking albums and + tracks. + """ + disambig: Sequence[str] = ( + get_album_disambig_fields(info) + if isinstance(info, hooks.AlbumInfo) + else get_singleton_disambig_fields(info) + ) + + return ", ".join(disambig) + + +def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]: + out: MutableSequence[str] = [] + chosen_fields: Sequence[str] = config["match"][ + "singleton_disambig_fields" + ].as_str_seq() + calculated_values: dict[str, str] = { + "index": f"Index {info.index}", + "track_alt": f"Track {info.track_alt}", + "album": ( + f"[{info.album}]" + if ( + config["import"]["singleton_album_disambig"].get() + and info.get("album") + ) + else "" + ), + } + + field: str + for field in chosen_fields: + if field in calculated_values: + out.append(str(calculated_values[field])) + else: + try: + out.append(str(info[field])) + except (AttributeError, KeyError): + print(f"Disambiguation string key {field} does not exist.") + + return out + + +def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]: + out: MutableSequence[str] = [] + chosen_fields: Sequence[str] = config["match"][ + "album_disambig_fields" + ].as_str_seq() + calculated_values: dict[str, str | None] = { + "media": ( + f"{info.mediums}x{info.media}" + if (info.mediums and info.mediums > 1) + else info.media + ), + } + + field: str + for field in chosen_fields: + if field in calculated_values: + out.append(str(calculated_values[field])) + else: + try: + out.append(str(info[field])) + except (AttributeError, KeyError): + print(f"Disambiguation string key {field} does not exist.") + + return out + + +def dist_colorize(string: str, dist: Distance | int | float) -> str: + """Formats a string as a colorized similarity string according to + a distance. + """ + if dist <= config["match"]["strong_rec_thresh"].as_number(): + string = colorize("text_success", string) + elif dist <= config["match"]["medium_rec_thresh"].as_number(): + string = colorize("text_warning", string) + else: + string = colorize("text_error", string) + return string + + +def dist_string(dist: Distance | float | int) -> str: + """Formats a distance (a float) as a colorized similarity percentage + string. + """ + string: str = f"{(1 - dist) * 100:.1f}%" + return dist_colorize(string, dist) + + +def penalty_string(distance: Distance, limit: int | None = None): + """Returns a colorized string that indicates all the penalties + applied to a distance object. + """ + penalties: list[str] = [] + key: str + for key in distance.keys(): + key = key.replace("album_", "") + key = key.replace("track_", "") + key = key.replace("_", " ") + penalties.append(key) + if penalties: + if limit and len(penalties) > limit: + penalties = penalties[:limit] + ["..."] + # Prefix penalty string with U+2260: Not Equal To + penalty_string: str = f"\u2260 {', '.join(penalties)}" + return colorize("changed", penalty_string) diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py new file mode 100644 index 0000000000..79cc08373f --- /dev/null +++ b/beets/ui/commands/import_/session.py @@ -0,0 +1,706 @@ +from __future__ import annotations + +from collections import Counter +from itertools import chain +from typing import TYPE_CHECKING, NamedTuple, overload + +from typing_extensions import override + +from beets import autotag, config, importer, logging, plugins +from beets.autotag import Recommendation, TrackMatch +from beets.ui.colors import colorize +from beets.ui.core import input_, input_options, input_yn, print_ +from beets.util import displayable_path +from beets.util.units import human_bytes, human_seconds_short + +from .display import ( + disambig_string, + dist_colorize, + penalty_string, + show_change, + show_item_change, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from typing import Any, Literal + + from typing_extensions import Never + + from beets.autotag import AlbumMatch, Proposal + from beets.library import Album, Item + +# Global logger. +log: logging.BeetsLogger = logging.getLogger("beets") + + +class TerminalImportSession(importer.ImportSession): + """An import session that runs in a terminal.""" + + @override + def choose_match( + self, task: importer.ImportTask + ) -> AlbumMatch | Literal[importer.Action.ASIS, importer.Action.SKIP]: + """Given an initial autotagging of items, go through an interactive + dance with the user to ask for a choice of metadata. Returns an + AlbumMatch object, ASIS, or SKIP. + """ + # Show what we're tagging. + print_() + + path_str0: str = displayable_path(task.paths, "\n") + path_str: str = colorize("import_path", path_str0) + items_str0: str = f"({len(task.items)} items)" + items_str: str = colorize("import_path_items", items_str0) + print_(" ".join([path_str, items_str])) + + # Let plugins display info or prompt the user before we go through the + # process of selecting candidate. + results: list[Any] = plugins.send( + "import_task_before_choice", session=self, task=task + ) + actions: list[Any] = [action for action in results if action] + + if len(actions) == 1: + return actions[0] + elif len(actions) > 1: + raise plugins.PluginConflictError( + "Only one handler for `import_task_before_choice` may return " + "an action." + ) + + # Take immediate action if appropriate. + action: ( + Literal[ + importer.Action.APPLY, + importer.Action.ASIS, + importer.Action.SKIP, + ] + | None + ) = _summary_judgment(task.rec) + if action == importer.Action.APPLY: + match: AlbumMatch = task.candidates[0] + show_change(task.cur_artist, task.cur_album, match) + return match + elif action is not None: + return action + + # Loop until we have a choice. + while True: + # Ask for a choice from the user. The result of + # `choose_candidate` may be an `importer.Action`, an + # `AlbumMatch` object for a specific selection, or a + # `PromptChoice`. + choices: list[PromptChoice] = self._get_choices(task) + choice: ( + Literal[importer.Action.ASIS, importer.Action.SKIP] + | AlbumMatch + | PromptChoice + ) = choose_candidate( + task.candidates, + False, + task.rec, + task.cur_artist, + task.cur_album, + itemcount=len(task.items), + choices=choices, + ) + + # Basic choices that require no more action here. + if choice in (importer.Action.SKIP, importer.Action.ASIS): + # Pass selection to main control flow. + return choice + + # Plugin-provided choices. We invoke the associated callback + # function. + elif choice in choices: + assert choice.callback + post_choice: ( + Literal[importer.Action.ASIS, importer.Action.SKIP] + | Proposal + ) = choice.callback(self, task) + if isinstance(post_choice, importer.Action): + return post_choice + elif isinstance(post_choice, autotag.Proposal): + # Use the new candidates and continue around the loop. + task.candidates = post_choice.candidates + task.rec = post_choice.recommendation + + # Otherwise, we have a specific match selection. + else: + # We have a candidate! Finish tagging. Here, choice is an + # AlbumMatch object. + assert isinstance(choice, autotag.AlbumMatch) + return choice + + def choose_item( + self, task: importer.SingletonImportTask + ) -> Literal[importer.Action.ASIS, importer.Action.SKIP] | TrackMatch: + """Ask the user for a choice about tagging a single item. Returns + either an action constant or a TrackMatch object. + """ + print_() + print_(displayable_path(task.item.path)) + candidates: Sequence[AlbumMatch | TrackMatch] + rec: Recommendation | None + candidates, rec = task.candidates, task.rec + + # Take immediate action if appropriate. + action: ( + Literal[ + importer.Action.APPLY, + importer.Action.ASIS, + importer.Action.SKIP, + ] + | None + ) = _summary_judgment(task.rec) + if action == importer.Action.APPLY: + match: TrackMatch = candidates[0] + show_item_change(task.item, match) + return match + elif action is not None: + return action + + while True: + # Ask for a choice. + choices: list[PromptChoice] = self._get_choices(task) + choice: ( + Literal[importer.Action.ASIS, importer.Action.SKIP] + | TrackMatch + | PromptChoice + ) = choose_candidate( + candidates, True, rec, item=task.item, choices=choices + ) + + if choice in (importer.Action.SKIP, importer.Action.ASIS): + return choice + + elif choice in choices: + assert choice.callback + post_choice: ( + Proposal + | Literal[importer.Action.ASIS, importer.Action.SKIP] + ) = choice.callback(self, task) + if isinstance(post_choice, importer.Action): + return post_choice + elif isinstance(post_choice, autotag.Proposal): + candidates = post_choice.candidates + rec = post_choice.recommendation + + else: + # Chose a candidate. + assert isinstance(choice, autotag.TrackMatch) + return choice + + def resolve_duplicate( + self, task: importer.ImportTask, found_duplicates: list[Album] + ) -> None: + """Decide what to do when a new album or item seems similar to one + that's already in the library. + """ + log.warning( + "This {} is already in the library!", + ("album" if task.is_album else "item"), + ) + + sel: int | str + if config["import"]["quiet"]: + # In quiet mode, don't prompt -- just skip. + log.info("Skipping.") + sel = "s" + else: + # Print some detail about the existing and new items so the + # user can make an informed decision. + duplicate: Album + for duplicate in found_duplicates: + print_( + "Old: " + + summarize_items( + ( + list(duplicate.items()) + if task.is_album + else [duplicate] + ), + not task.is_album, + ) + ) + if config["import"]["duplicate_verbose_prompt"]: + if task.is_album: + dup: Item + for dup in duplicate.items(): + print(f" {dup}") + else: + print(f" {duplicate}") + + print_( + "New: " + + summarize_items( + task.imported_items(), + not task.is_album, + ) + ) + if config["import"]["duplicate_verbose_prompt"]: + item: Item + for item in task.imported_items(): + print(f" {item}") + + sel = input_options( + ("Skip new", "Keep all", "Remove old", "Merge all") + ) + + assert isinstance(sel, str) + if sel == "s": + # Skip new. + task.set_choice(importer.Action.SKIP) + elif sel == "k": + # Keep both. Do nothing; leave the choice intact. + pass + elif sel == "r": + # Remove old. + task.should_remove_duplicates = True + elif sel == "m": + task.should_merge_duplicates = True + else: + assert False + + def should_resume(self, path) -> bool: + return input_yn( + f"Import of the directory:\n{displayable_path(path)}\n" + "was interrupted. Resume (Y/n)?" + ) + + def _get_choices(self, task) -> list[PromptChoice]: + """Get the list of prompt choices that should be presented to the + user. This consists of both built-in choices and ones provided by + plugins. + + The `before_choose_candidate` event is sent to the plugins, with + session and task as its parameters. Plugins are responsible for + checking the right conditions and returning a list of `PromptChoice`s, + which is flattened and checked for conflicts. + + If two or more choices have the same short letter, a warning is + emitted and all but one choices are discarded, giving preference + to the default importer choices. + + Returns a list of `PromptChoice`s. + """ + # Standard, built-in choices. + choices: list[PromptChoice] = [ + PromptChoice("s", "Skip", lambda s, t: importer.Action.SKIP), + PromptChoice("u", "Use as-is", lambda s, t: importer.Action.ASIS), + ] + if task.is_album: + choices += [ + PromptChoice( + "t", "as Tracks", lambda s, t: importer.Action.TRACKS + ), + PromptChoice( + "g", "Group albums", lambda s, t: importer.Action.ALBUMS + ), + ] + choices += [ + PromptChoice("e", "Enter search", manual_search), + PromptChoice("i", "enter Id", manual_id), + PromptChoice("b", "aBort", abort_action), + ] + + # Send the before_choose_candidate event and flatten list. + extra_choices: list[PromptChoice] = list( + chain( + *plugins.send( + "before_choose_candidate", session=self, task=task + ) + ) + ) + + # Add a "dummy" choice for the other baked-in option, for + # duplicate checking. + all_choices: list[PromptChoice] = ( + [ + PromptChoice("a", "Apply", None), + ] + + choices + + extra_choices + ) + + # Check for conflicts. + short_letters: list[str] = [c.short for c in all_choices] + if len(short_letters) != len(set(short_letters)): + # Duplicate short letter has been found. + duplicates: list[str] = [ + i for i, count in Counter(short_letters).items() if count > 1 + ] + short: str + for short in duplicates: + # Keep the first of the choices, removing the rest. + dup_choices: list[PromptChoice] = [ + c for c in all_choices if c.short == short + ] + c: PromptChoice + for c in dup_choices[1:]: + log.warning( + "Prompt choice '{0.long}' removed due to conflict " + "with '{1[0].long}' (short letter: '{0.short}')", + c, + dup_choices, + ) + extra_choices.remove(c) + + return choices + extra_choices + + +def summarize_items(items: Sequence[Item], singleton): + """Produces a brief summary line describing a set of items. Used for + manually resolving duplicates during import. + + `items` is a list of `Item` objects. `singleton` indicates whether + this is an album or single-item import (if the latter, them `items` + should only have one element). + """ + summary_parts: list[str] = [] + if not singleton: + summary_parts.append(f"{len(items)} items") + + format_counts: dict[str, int] = {} + item: Item + for item in items: + format_counts[item.format] = format_counts.get(item.format, 0) + 1 + if len(format_counts) == 1: + # A single format. + summary_parts.append(items[0].format) + else: + # Enumerate all the formats by decreasing frequencies: + fmt: str + count: int + for fmt, count in sorted( + format_counts.items(), + key=lambda fmt_and_count: (-fmt_and_count[1], fmt_and_count[0]), + ): + summary_parts.append(f"{fmt} {count}") + + if items: + average_bitrate: float = sum([item.bitrate for item in items]) / len( + items + ) + total_duration: int = sum([item.length for item in items]) + total_filesize: int = sum([item.filesize for item in items]) + summary_parts.append(f"{int(average_bitrate / 1000)}kbps") + if items[0].format == "FLAC": + sample_bits: str = ( + f"{round(int(items[0].samplerate) / 1000, 1)}kHz" + f"/{items[0].bitdepth} bit" + ) + summary_parts.append(sample_bits) + summary_parts.append(human_seconds_short(total_duration)) + summary_parts.append(human_bytes(total_filesize)) + + return ", ".join(summary_parts) + + +def _summary_judgment( + rec, +) -> ( + Literal[importer.Action.APPLY, importer.Action.ASIS, importer.Action.SKIP] + | None +): + """Determines whether a decision should be made without even asking + the user. This occurs in quiet mode and when an action is chosen for + NONE recommendations. Return None if the user should be queried. + Otherwise, returns an action. May also print to the console if a + summary judgment is made. + """ + + action: ( + Literal[ + importer.Action.APPLY, + importer.Action.ASIS, + importer.Action.SKIP, + ] + | None + ) + if config["import"]["quiet"]: + if rec == Recommendation.strong: + return importer.Action.APPLY + else: + action = config["import"]["quiet_fallback"].as_choice( + { + "skip": importer.Action.SKIP, + "asis": importer.Action.ASIS, + } + ) + elif config["import"]["timid"]: + return None + elif rec == Recommendation.none: + action = config["import"]["none_rec_action"].as_choice( + { + "skip": importer.Action.SKIP, + "asis": importer.Action.ASIS, + "ask": None, + } + ) + else: + return None + + if action == importer.Action.SKIP: + print_("Skipping.") + elif action == importer.Action.ASIS: + print_("Importing as-is.") + return action + + +class PromptChoice(NamedTuple): + short: str + long: str + callback: ( + Callable[ + [TerminalImportSession, importer.ImportTask], + importer.Action | Proposal, + ] + | None + ) + + +@overload +def choose_candidate( + candidates: Sequence[TrackMatch], + singleton: Literal[True], + rec: Recommendation | None, + cur_artist: str | None = None, + cur_album: str | None = None, + item: Item | None = None, + itemcount: int | None = None, + choices: list[PromptChoice] = [], +) -> ( + Literal[importer.Action.SKIP, importer.Action.ASIS] + | TrackMatch + | PromptChoice +): ... + + +@overload +def choose_candidate( + candidates: Sequence[AlbumMatch], + singleton: Literal[False], + rec: Recommendation | None, + cur_artist: str | None = None, + cur_album: str | None = None, + item: Item | None = None, + itemcount: int | None = None, + choices: list[PromptChoice] = [], +) -> ( + Literal[importer.Action.SKIP, importer.Action.ASIS] + | AlbumMatch + | PromptChoice +): ... + + +def choose_candidate( + candidates: Sequence[AlbumMatch | TrackMatch], + singleton: bool, + rec: Recommendation | None, + cur_artist: str | None = None, + cur_album: str | None = None, + item: Item | None = None, + itemcount: int | None = None, + choices: list[PromptChoice] = [], +) -> ( + Literal[importer.Action.SKIP, importer.Action.ASIS] + | AlbumMatch + | TrackMatch + | PromptChoice +): + """Given a sorted list of candidates, ask the user for a selection + of which candidate to use. Applies to both full albums and + singletons (tracks). Candidates are either AlbumMatch or TrackMatch + objects depending on `singleton`. for albums, `cur_artist`, + `cur_album`, and `itemcount` must be provided. For singletons, + `item` must be provided. + + `choices` is a list of `PromptChoice`s to be used in each prompt. + + Returns one of the following: + * the result of the choice, which may be SKIP or ASIS + * a candidate (an AlbumMatch/TrackMatch object) + * a chosen `PromptChoice` from `choices` + """ + # Sanity check. + if singleton: + assert item is not None + else: + assert cur_artist is not None + assert cur_album is not None + + # Build helper variables for the prompt choices. + choice_opts: tuple[str, ...] = tuple(c.long for c in choices) + choice_actions: dict[str, PromptChoice] = {c.short: c for c in choices} + + # Zero candidates. + sel: int | str + if not candidates: + if singleton: + print_("No matching recordings found.") + else: + print_(f"No matching release found for {itemcount} tracks.") + print_( + "For help, see: " + "https://beets.readthedocs.org/en/latest/faq.html#nomatch" + ) + sel = input_options(choice_opts) + if isinstance(sel, str) and sel in choice_actions: + return choice_actions[sel] + else: + assert False + + # Is the change good enough? + bypass_candidates: bool = rec != Recommendation.none + match: AlbumMatch | TrackMatch = candidates[0] + + while True: + # Display and choose from candidates. + require: bool = not rec or (rec <= Recommendation.low) + + if not bypass_candidates: + # Display list of candidates. + print_("") + artist: str | None + title: str | None + print_( + f'Finding tags for {"track" if singleton else "album"} "' + + str( + artist + if singleton and item and (artist := item.artist) + else cur_artist + ) + + " - " + + str( + title + if singleton and item and (title := item.title) + else cur_album + ) + + '".' + ) + + print_(" Candidates:") + i: int + for i, match in enumerate(candidates): + # Index, metadata, and distance. + index0: str = f"{i + 1}." + index: str = dist_colorize(index0, match.distance) + dist: str = f"({(1 - match.distance) * 100:.1f}%)" + distance: str = dist_colorize(dist, match.distance) + metadata: str = ( + f"{match.info.artist} -" + f" {match.info.title if singleton else match.info.album}" + ) + if i == 0: + metadata = dist_colorize(metadata, match.distance) + else: + metadata = colorize("text_highlight_minor", metadata) + line1: list[str] = [index, distance, metadata] + print_(f" {' '.join(line1)}") + + # Penalties. + penalties: str | None = penalty_string(match.distance, 3) + if penalties: + print_(f"{' ' * 13}{penalties}") + + # Disambiguation + disambig: str | None = disambig_string(match.info) + if disambig: + print_(f"{' ' * 13}{disambig}") + + # Ask the user for a choice. + sel = input_options(choice_opts, numrange=(1, len(candidates))) + if isinstance(sel, int): # Numerical selection. + match = candidates[sel - 1] + if sel != 1: + # When choosing anything but the first match, + # disable the default action. + require = True + elif sel == "m": + pass + elif sel in choice_actions: + return choice_actions[sel] + + bypass_candidates = False + + # Show what we're about to do. + if singleton and isinstance(match, TrackMatch): + show_item_change(item, match) + else: + show_change(cur_artist, cur_album, match) + + # Exact match => tag automatically if we're not in timid mode. + if rec == Recommendation.strong and not config["import"]["timid"]: + return match + + # Ask for confirmation. + default: str | None = config["import"]["default_action"].as_choice( + { + "apply": "a", + "skip": "s", + "asis": "u", + "none": None, + } + ) + if default is None: + require = True + # Bell ring when user interaction is needed. + if config["import"]["bell"]: + print_("\a", end="") + sel = input_options( + ("Apply", "More candidates") + choice_opts, + require=require, + default=default, + ) + if not isinstance(sel, str): + continue + if sel == "a": + return match + elif sel in choice_actions: + return choice_actions[sel] + + +def manual_search( + session: TerminalImportSession, task: importer.ImportTask +) -> Proposal: + """Get a new `Proposal` using manual search criteria. + + Input either an artist and album (for full albums) or artist and + track name (for singletons) for manual search. + """ + artist: str = input_("Artist:").strip() + name: str = input_("Album:" if task.is_album else "Track:").strip() + + if not task.is_album and isinstance(task, importer.SingletonImportTask): + return autotag.tag_item(task.item, artist, name) + else: + prop: Proposal + _, _, prop = autotag.tag_album(task.items, artist, name) + return prop + + +def manual_id( + session: TerminalImportSession, task: importer.ImportTask +) -> Proposal: + """Get a new `Proposal` using a manually-entered ID. + + Input an ID, either for an album ("release") or a track ("recording"). + """ + prompt: str = f"Enter {'release' if task.is_album else 'recording'} ID:" + search_id: str = input_(prompt).strip() + + if not task.is_album and isinstance(task, importer.SingletonImportTask): + return autotag.tag_item(task.item, search_ids=search_id.split()) + else: + prop: Proposal + _, _, prop = autotag.tag_album(task.items, search_ids=search_id.split()) + return prop + + +def abort_action( + session: TerminalImportSession, task: importer.ImportTask +) -> Never: + """A prompt choice callback that aborts the importer.""" + raise importer.ImportAbortError() diff --git a/beets/ui/commands/list.py b/beets/ui/commands/list.py new file mode 100644 index 0000000000..1bae3c8924 --- /dev/null +++ b/beets/ui/commands/list.py @@ -0,0 +1,25 @@ +"""The 'list' command: query and show library contents.""" + +from beets.ui.core import Subcommand, print_ + + +def list_items(lib, query, album, fmt=""): + """Print out items in lib matching query. If album, then search for + albums instead of single items. + """ + if album: + for album in lib.albums(query): + print_(format(album, fmt)) + else: + for item in lib.items(query): + print_(format(item, fmt)) + + +def list_func(lib, opts, args): + list_items(lib, args, opts.album) + + +list_cmd = Subcommand("list", help="query the library", aliases=("ls",)) +list_cmd.parser.usage += "\nExample: %prog -f '$album: $title' artist:beatles" +list_cmd.parser.add_all_common_options() +list_cmd.func = list_func diff --git a/beets/ui/commands/modify.py b/beets/ui/commands/modify.py new file mode 100644 index 0000000000..fc24d50996 --- /dev/null +++ b/beets/ui/commands/modify.py @@ -0,0 +1,171 @@ +"""The `modify` command: change metadata fields.""" + +from beets import library +from beets.ui._common import UserError +from beets.ui.core import ( + Subcommand, + input_select_objects, + print_, + should_move, + should_write, + show_model_changes, +) +from beets.util import functemplate + +from ._utils import do_query + + +def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): + """Modifies matching items according to user-specified assignments and + deletions. + + `mods` is a dictionary of field and value pairse indicating + assignments. `dels` is a list of fields to be deleted. + """ + # Parse key=value specifications into a dictionary. + model_cls = library.Album if album else library.Item + + # Get the items to modify. + items, albums = do_query(lib, query, album, False) + objs = albums if album else items + + # Apply changes *temporarily*, preview them, and collect modified + # objects. + print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.") + changed = [] + templates = { + key: functemplate.template(value) for key, value in mods.items() + } + for obj in objs: + obj_mods = { + key: model_cls._parse(key, obj.evaluate_template(templates[key])) + for key in mods.keys() + } + if print_and_modify(obj, obj_mods, dels) and obj not in changed: + changed.append(obj) + + # Still something to do? + if not changed: + print_("No changes to make.") + return + + # Confirm action. + if confirm: + if write and move: + extra = ", move and write tags" + elif write: + extra = " and write tags" + elif move: + extra = " and move" + else: + extra = "" + + changed = input_select_objects( + f"Really modify{extra}", + changed, + lambda o: print_and_modify(o, mods, dels), + ) + + # Apply changes to database and files + with lib.transaction(): + for obj in changed: + obj.try_sync(write, move, inherit) + + +def print_and_modify(obj, mods, dels): + """Print the modifications to an item and return a bool indicating + whether any changes were made. + + `mods` is a dictionary of fields and values to update on the object; + `dels` is a sequence of fields to delete. + """ + obj.update(mods) + for field in dels: + try: + del obj[field] + except KeyError: + pass + return show_model_changes(obj) + + +def modify_parse_args(args): + """Split the arguments for the modify subcommand into query parts, + assignments (field=value), and deletions (field!). Returns the result as + a three-tuple in that order. + """ + mods = {} + dels = [] + query = [] + for arg in args: + if arg.endswith("!") and "=" not in arg and ":" not in arg: + dels.append(arg[:-1]) # Strip trailing !. + elif "=" in arg and ":" not in arg.split("=", 1)[0]: + key, val = arg.split("=", 1) + mods[key] = val + else: + query.append(arg) + return query, mods, dels + + +def modify_func(lib, opts, args): + query, mods, dels = modify_parse_args(args) + if not mods and not dels: + raise UserError("no modifications specified") + modify_items( + lib, + mods, + dels, + query, + should_write(opts.write), + should_move(opts.move), + opts.album, + not opts.yes, + opts.inherit, + ) + + +modify_cmd = Subcommand( + "modify", help="change metadata fields", aliases=("mod",) +) +modify_cmd.parser.add_option( + "-m", + "--move", + action="store_true", + dest="move", + help="move files in the library directory", +) +modify_cmd.parser.add_option( + "-M", + "--nomove", + action="store_false", + dest="move", + help="don't move files in library", +) +modify_cmd.parser.add_option( + "-w", + "--write", + action="store_true", + default=None, + help="write new metadata to files' tags (default)", +) +modify_cmd.parser.add_option( + "-W", + "--nowrite", + action="store_false", + dest="write", + help="don't write metadata (opposite of -w)", +) +modify_cmd.parser.add_album_option() +modify_cmd.parser.add_format_option(target="item") +modify_cmd.parser.add_option( + "-y", "--yes", action="store_true", help="skip confirmation" +) +modify_cmd.parser.add_option( + "-I", + "--noinherit", + action="store_false", + dest="inherit", + default=True, + help="when modifying albums, don't also change item data", +) +modify_cmd.func = modify_func diff --git a/beets/ui/commands/move.py b/beets/ui/commands/move.py new file mode 100644 index 0000000000..f7d00637e5 --- /dev/null +++ b/beets/ui/commands/move.py @@ -0,0 +1,154 @@ +"""The 'move' command: Move/copy files to the library or a new base directory.""" + +import os + +from beets import logging, util +from beets.ui._common import UserError +from beets.ui.core import Subcommand, input_select_objects, show_path_changes + +from ._utils import do_query + +# Global logger. +log = logging.getLogger("beets") + + +def move_items( + lib, + dest_path: util.PathLike, + query, + copy, + album, + pretend, + confirm=False, + export=False, +): + """Moves or copies items to a new base directory, given by dest. If + dest is None, then the library's base directory is used, making the + command "consolidate" files. + """ + dest = os.fsencode(dest_path) if dest_path else dest_path + items, albums = do_query(lib, query, album, False) + objs = albums if album else items + num_objs = len(objs) + + # Filter out files that don't need to be moved. + def isitemmoved(item): + return item.path != item.destination(basedir=dest) + + def isalbummoved(album): + return any(isitemmoved(i) for i in album.items()) + + objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)] + num_unmoved = num_objs - len(objs) + # Report unmoved files that match the query. + unmoved_msg = "" + if num_unmoved > 0: + unmoved_msg = f" ({num_unmoved} already in place)" + + copy = copy or export # Exporting always copies. + action = "Copying" if copy else "Moving" + act = "copy" if copy else "move" + entity = "album" if album else "item" + log.info( + "{} {} {}{}{}.", + action, + len(objs), + entity, + "s" if len(objs) != 1 else "", + unmoved_msg, + ) + if not objs: + return + + if pretend: + if album: + show_path_changes( + [ + (item.path, item.destination(basedir=dest)) + for obj in objs + for item in obj.items() + ] + ) + else: + show_path_changes( + [(obj.path, obj.destination(basedir=dest)) for obj in objs] + ) + else: + if confirm: + objs = input_select_objects( + f"Really {act}", + objs, + lambda o: show_path_changes( + [(o.path, o.destination(basedir=dest))] + ), + ) + + for obj in objs: + log.debug("moving: {.filepath}", obj) + + if export: + # Copy without affecting the database. + obj.move( + operation=util.MoveOperation.COPY, basedir=dest, store=False + ) + else: + # Ordinary move/copy: store the new path. + if copy: + obj.move(operation=util.MoveOperation.COPY, basedir=dest) + else: + obj.move(operation=util.MoveOperation.MOVE, basedir=dest) + + +def move_func(lib, opts, args): + dest = opts.dest + if dest is not None: + dest = util.normpath(dest) + if not os.path.isdir(util.syspath(dest)): + raise UserError(f"no such directory: {util.displayable_path(dest)}") + + move_items( + lib, + dest, + args, + opts.copy, + opts.album, + opts.pretend, + opts.timid, + opts.export, + ) + + +move_cmd = Subcommand("move", help="move or copy items", aliases=("mv",)) +move_cmd.parser.add_option( + "-d", "--dest", metavar="DIR", dest="dest", help="destination directory" +) +move_cmd.parser.add_option( + "-c", + "--copy", + default=False, + action="store_true", + help="copy instead of moving", +) +move_cmd.parser.add_option( + "-p", + "--pretend", + default=False, + action="store_true", + help="show how files would be moved, but don't touch anything", +) +move_cmd.parser.add_option( + "-t", + "--timid", + dest="timid", + action="store_true", + help="always confirm all actions", +) +move_cmd.parser.add_option( + "-e", + "--export", + default=False, + action="store_true", + help="copy without changing the database path", +) +move_cmd.parser.add_album_option() +move_cmd.func = move_func diff --git a/beets/ui/commands/remove.py b/beets/ui/commands/remove.py new file mode 100644 index 0000000000..32cbd535bf --- /dev/null +++ b/beets/ui/commands/remove.py @@ -0,0 +1,84 @@ +"""The `remove` command: remove items from the library (and optionally delete files).""" + +from beets.ui.core import Subcommand, input_select_objects, print_ + +from ._utils import do_query + + +def remove_items(lib, query, album, delete, force): + """Remove items matching query from lib. If album, then match and + remove whole albums. If delete, also remove files from disk. + """ + # Get the matching items. + items, albums = do_query(lib, query, album) + objs = albums if album else items + + # Confirm file removal if not forcing removal. + if not force: + # Prepare confirmation with user. + album_str = ( + f" in {len(albums)} album{'s' if len(albums) > 1 else ''}" + if album + else "" + ) + + if delete: + fmt = "$path - $title" + prompt = "Really DELETE" + prompt_all = ( + "Really DELETE" + f" {len(items)} file{'s' if len(items) > 1 else ''}{album_str}" + ) + else: + fmt = "" + prompt = "Really remove from the library?" + prompt_all = ( + "Really remove" + f" {len(items)} item{'s' if len(items) > 1 else ''}{album_str}" + " from the library?" + ) + + # Helpers for printing affected items + def fmt_track(t): + print_(format(t, fmt)) + + def fmt_album(a): + print_() + for i in a.items(): + fmt_track(i) + + fmt_obj = fmt_album if album else fmt_track + + # Show all the items. + for o in objs: + fmt_obj(o) + + # Confirm with user. + objs = input_select_objects( + prompt, objs, fmt_obj, prompt_all=prompt_all + ) + + if not objs: + return + + # Remove (and possibly delete) items. + with lib.transaction(): + for obj in objs: + obj.remove(delete) + + +def remove_func(lib, opts, args): + remove_items(lib, args, opts.album, opts.delete, opts.force) + + +remove_cmd = Subcommand( + "remove", help="remove matching items from the library", aliases=("rm",) +) +remove_cmd.parser.add_option( + "-d", "--delete", action="store_true", help="also remove files from disk" +) +remove_cmd.parser.add_option( + "-f", "--force", action="store_true", help="do not ask when removing items" +) +remove_cmd.parser.add_album_option() +remove_cmd.func = remove_func diff --git a/beets/ui/commands/stats.py b/beets/ui/commands/stats.py new file mode 100644 index 0000000000..1b2053c481 --- /dev/null +++ b/beets/ui/commands/stats.py @@ -0,0 +1,63 @@ +"""The 'stats' command: show library statistics.""" + +import os + +from beets import logging +from beets.ui.core import Subcommand, print_ +from beets.util import syspath +from beets.util.units import human_bytes, human_seconds + +# Global logger. +log = logging.getLogger("beets") + + +def show_stats(lib, query, exact): + """Shows some statistics about the matched items.""" + items = lib.items(query) + + total_size = 0 + total_time = 0.0 + total_items = 0 + artists = set() + albums = set() + album_artists = set() + + for item in items: + if exact: + try: + total_size += os.path.getsize(syspath(item.path)) + except OSError as exc: + log.info("could not get size of {.path}: {}", item, exc) + else: + total_size += int(item.length * item.bitrate / 8) + total_time += item.length + total_items += 1 + artists.add(item.artist) + album_artists.add(item.albumartist) + if item.album_id: + albums.add(item.album_id) + + size_str = human_bytes(total_size) + if exact: + size_str += f" ({total_size} bytes)" + + print_(f"""Tracks: {total_items} +Total time: {human_seconds(total_time)} +{f" ({total_time:.2f} seconds)" if exact else ""} +{"Total size" if exact else "Approximate total size"}: {size_str} +Artists: {len(artists)} +Albums: {len(albums)} +Album artists: {len(album_artists)}""") + + +def stats_func(lib, opts, args): + show_stats(lib, args, opts.exact) + + +stats_cmd = Subcommand( + "stats", help="show statistics about the library or a query" +) +stats_cmd.parser.add_option( + "-e", "--exact", action="store_true", help="exact size and time" +) +stats_cmd.func = stats_func diff --git a/beets/ui/commands/update.py b/beets/ui/commands/update.py new file mode 100644 index 0000000000..098d19048c --- /dev/null +++ b/beets/ui/commands/update.py @@ -0,0 +1,204 @@ +"""The `update` command: Update library contents according to on-disk tags.""" + +import os + +from beets import library, logging +from beets.ui.colors import colorize +from beets.ui.core import ( + Subcommand, + input_yn, + print_, + should_move, + show_model_changes, +) +from beets.util import ancestry, syspath + +from ._utils import do_query + +# Global logger. +log = logging.getLogger("beets") + + +def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): + """For all the items matched by the query, update the library to + reflect the item's embedded tags. + :param fields: The fields to be stored. If not specified, all fields will + be. + :param exclude_fields: The fields to not be stored. If not specified, all + fields will be. + """ + with lib.transaction(): + items, _ = do_query(lib, query, album) + if move and fields is not None and "path" not in fields: + # Special case: if an item needs to be moved, the path field has to + # updated; otherwise the new path will not be reflected in the + # database. + fields.append("path") + if fields is None: + # no fields were provided, update all media fields + item_fields = fields or library.Item._media_fields + if move and "path" not in item_fields: + # move is enabled, add 'path' to the list of fields to update + item_fields.add("path") + else: + # fields was provided, just update those + item_fields = fields + # get all the album fields to update + album_fields = fields or library.Album._fields.keys() + if exclude_fields: + # remove any excluded fields from the item and album sets + item_fields = [f for f in item_fields if f not in exclude_fields] + album_fields = [f for f in album_fields if f not in exclude_fields] + + # Walk through the items and pick up their changes. + affected_albums = set() + for item in items: + # Item deleted? + if not item.path or not os.path.exists(syspath(item.path)): + print_(format(item)) + print_(colorize("text_error", " deleted")) + if not pretend: + item.remove(True) + affected_albums.add(item.album_id) + continue + + # Did the item change since last checked? + if item.current_mtime() <= item.mtime: + log.debug( + "skipping {0.filepath} because mtime is up to date ({0.mtime})", + item, + ) + continue + + # Read new data. + try: + item.read() + except library.ReadError as exc: + log.error("error reading {.filepath}: {}", item, exc) + continue + + # Special-case album artist when it matches track artist. (Hacky + # but necessary for preserving album-level metadata for non- + # autotagged imports.) + if not item.albumartist: + old_item = lib.get_item(item.id) + if old_item.albumartist == old_item.artist == item.artist: + item.albumartist = old_item.albumartist + item._dirty.discard("albumartist") + + # Check for and display changes. + changed = show_model_changes(item, fields=item_fields) + + # Save changes. + if not pretend: + if changed: + # Move the item if it's in the library. + if move and lib.directory in ancestry(item.path): + item.move(store=False) + + item.store(fields=item_fields) + affected_albums.add(item.album_id) + else: + # The file's mtime was different, but there were no + # changes to the metadata. Store the new mtime, + # which is set in the call to read(), so we don't + # check this again in the future. + item.store(fields=item_fields) + + # Skip album changes while pretending. + if pretend: + return + + # Modify affected albums to reflect changes in their items. + for album_id in affected_albums: + if album_id is None: # Singletons. + continue + album = lib.get_album(album_id) + if not album: # Empty albums have already been removed. + log.debug("emptied album {}", album_id) + continue + first_item = album.items().get() + + # Update album structure to reflect an item in it. + for key in library.Album.item_keys: + album[key] = first_item[key] + album.store(fields=album_fields) + + # Move album art (and any inconsistent items). + if move and lib.directory in ancestry(first_item.path): + log.debug("moving album {}", album_id) + + # Manually moving and storing the album. + items = list(album.items()) + for item in items: + item.move(store=False, with_album=False) + item.store(fields=item_fields) + album.move(store=False) + album.store(fields=album_fields) + + +def update_func(lib, opts, args): + # Verify that the library folder exists to prevent accidental wipes. + if not os.path.isdir(syspath(lib.directory)): + print_("Library path is unavailable or does not exist.") + print_(lib.directory) + if not input_yn("Are you sure you want to continue (y/n)?", True): + return + update_items( + lib, + args, + opts.album, + should_move(opts.move), + opts.pretend, + opts.fields, + opts.exclude_fields, + ) + + +update_cmd = Subcommand( + "update", + help="update the library", + aliases=( + "upd", + "up", + ), +) +update_cmd.parser.add_album_option() +update_cmd.parser.add_format_option() +update_cmd.parser.add_option( + "-m", + "--move", + action="store_true", + dest="move", + help="move files in the library directory", +) +update_cmd.parser.add_option( + "-M", + "--nomove", + action="store_false", + dest="move", + help="don't move files in library", +) +update_cmd.parser.add_option( + "-p", + "--pretend", + action="store_true", + help="show all changes but do nothing", +) +update_cmd.parser.add_option( + "-F", + "--field", + default=None, + action="append", + dest="fields", + help="list of fields to update", +) +update_cmd.parser.add_option( + "-e", + "--exclude-field", + default=None, + action="append", + dest="exclude_fields", + help="list of fields to exclude from updates", +) +update_cmd.func = update_func diff --git a/beets/ui/commands/version.py b/beets/ui/commands/version.py new file mode 100644 index 0000000000..04bfa96e88 --- /dev/null +++ b/beets/ui/commands/version.py @@ -0,0 +1,24 @@ +"""The 'version' command: show version information.""" + +from platform import python_version + +import beets +from beets import plugins +from beets.ui.core import Subcommand, print_ + + +def show_version(*args): + print_(f"beets version {beets.__version__}") + print_(f"Python version {python_version()}") + # Show plugins. + names = sorted(p.name for p in plugins.find_plugins()) + if names: + print_("plugins:", ", ".join(names)) + else: + print_("no plugins loaded") + + +version_cmd = Subcommand("version", help="output version information") +version_cmd.func = show_version + +__all__ = ["version_cmd"] diff --git a/beets/ui/commands/write.py b/beets/ui/commands/write.py new file mode 100644 index 0000000000..64a95036f8 --- /dev/null +++ b/beets/ui/commands/write.py @@ -0,0 +1,61 @@ +"""The `write` command: write tag information to files.""" + +import os + +from beets import library, logging +from beets.ui.core import Subcommand, show_model_changes +from beets.util import syspath + +from ._utils import do_query + +# Global logger. +log = logging.getLogger("beets") + + +def write_items(lib, query, pretend, force): + """Write tag information from the database to the respective files + in the filesystem. + """ + items, albums = do_query(lib, query, False, False) + + for item in items: + # Item deleted? + if not os.path.exists(syspath(item.path)): + log.info("missing file: {.filepath}", item) + continue + + # Get an Item object reflecting the "clean" (on-disk) state. + try: + clean_item = library.Item.from_path(item.path) + except library.ReadError as exc: + log.error("error reading {.filepath}: {}", item, exc) + continue + + # Check for and display changes. + changed = show_model_changes( + item, clean_item, library.Item._media_tag_fields, force + ) + if (changed or force) and not pretend: + # We use `try_sync` here to keep the mtime up to date in the + # database. + item.try_sync(True, False) + + +def write_func(lib, opts, args): + write_items(lib, args, opts.pretend, opts.force) + + +write_cmd = Subcommand("write", help="write tag information to files") +write_cmd.parser.add_option( + "-p", + "--pretend", + action="store_true", + help="show all changes but do nothing", +) +write_cmd.parser.add_option( + "-f", + "--force", + action="store_true", + help="write tags even if the existing tags match the database", +) +write_cmd.func = write_func diff --git a/beets/ui/core.py b/beets/ui/core.py new file mode 100644 index 0000000000..b29a6d250e --- /dev/null +++ b/beets/ui/core.py @@ -0,0 +1,1384 @@ +from __future__ import annotations + +import optparse +import re +import struct +import sys +import textwrap +import warnings +from typing import TYPE_CHECKING, TypeVar + +from beets import config, library, util +from beets.ui._common import UserError +from beets.ui.colors import ( + ESC_TEXT_REGEX, + RESET_COLOR, + ColorName, + color_len, + color_split, + colordiff, + colorize, + uncolorize, +) +from beets.util import as_string +from beets.util.functemplate import Template, template + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + from typing import Any, Final, TextIO, TypedDict + + import confuse + from typing_extensions import NotRequired, Unpack + + from beets.dbcore import db + + class ColumnLayout(TypedDict): + prefix: str + contents: str + suffix: str + width: NotRequired[int] + + class OptionParserParams(TypedDict): + usage: NotRequired[str | None] + option_list: NotRequired[Iterable[optparse.Option] | None] + option_class: NotRequired[type[optparse.Option]] + version: NotRequired[str | None] + conflict_handler: NotRequired[str] + description: NotRequired[str | None] + formatter: NotRequired[optparse.HelpFormatter | None] + add_help_option: NotRequired[bool] + prog: NotRequired[str | None] + epilog: NotRequired[str | None] + + +if not sys.version_info < (3, 12): + from typing import override # pyright: ignore[reportUnreachable] +else: + from typing_extensions import override + + +T = TypeVar("T") + + +PF_KEY_QUERIES: Final = { + "comp": "comp:true", + "singleton": "singleton:true", +} + + +# Encoding utilities. + + +def _in_encoding() -> str: + """Get the encoding to use for *inputting* strings from the console.""" + return _stream_encoding(sys.stdin) + + +def _out_encoding() -> str: + """Get the encoding to use for *outputting* strings to the console.""" + return _stream_encoding(sys.stdout) + + +def _stream_encoding(stream: TextIO | Any, default: str = "utf-8") -> str: + """A helper for `_in_encoding` and `_out_encoding`: get the stream's + preferred encoding, using a configured override or a default + fallback if neither is not specified. + """ + # Configured override? + encoding: str + if encoding := config["terminal_encoding"].get(): + return encoding + + # For testing: When sys.stdout or sys.stdin is a StringIO under the + # test harness, it doesn't have an `encoding` attribute. Just use + # UTF-8. + if not hasattr(stream, "encoding"): + return default + + # Python's guessed output stream encoding, or UTF-8 as a fallback + # (e.g., when piped to a file). + return stream.encoding or default + + +def decargs(arglist: list[bytes]) -> list[bytes]: + """Given a list of command-line argument bytestrings, attempts to + decode them to Unicode strings when running under Python 2. + + .. 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, + ) + return arglist + + +def print_(*strings: str, end: str = "\n") -> None: + """Like print, but rather than raising an error when a character + is not in the terminal's encoding's character set, just silently + replaces it. + + The `end` keyword argument behaves similarly to the built-in `print` + (it defaults to a newline). + """ + txt: str = f"{' '.join(strings or ('',))}{end}" + + # Encode the string and write it to stdout. + # On Python 3, sys.stdout expects text strings and uses the + # exception-throwing encoding error policy. To avoid throwing + # errors and use our configurable encoding override, we use the + # underlying bytes buffer instead. + if hasattr(sys.stdout, "buffer"): + out: bytes = txt.encode(_out_encoding(), "replace") + _ = sys.stdout.buffer.write(out) + _ = sys.stdout.buffer.flush() + else: + # In our test harnesses (e.g., DummyOut), sys.stdout.buffer + # does not exist. We instead just record the text string. + _ = sys.stdout.write(txt) + + +# Configuration wrappers. + + +def _bool_fallback(a: bool | None, b: bool | None) -> bool: + """Given a boolean or None, return the original value or a fallback.""" + if a is None: + assert isinstance(b, bool) + return b + else: + assert isinstance(a, bool) + return a + + +def should_write(write_opt: bool | None = None) -> bool: + """Decide whether a command that updates metadata should also write + tags, using the importer configuration as the default. + """ + return _bool_fallback(write_opt, config["import"]["write"].get(bool)) + + +def should_move(move_opt: bool | None = None) -> bool: + """Decide whether a command that updates metadata should also move + files when they're inside the library, using the importer + configuration as the default. + + Specifically, commands should move files after metadata updates only + when the importer is configured *either* to move *or* to copy files. + They should avoid moving files when the importer is configured not + to touch any filenames. + """ + return _bool_fallback( + move_opt, + config["import"]["move"].get(bool) + or config["import"]["copy"].get(bool), + ) + + +# Input prompts. + + +def indent(count: int) -> str: + """Returns a string with `count` many spaces.""" + return " " * count + + +def input_(prompt: str | None = None) -> str: + """Like `input`, but decodes the result to a Unicode string. + Raises a UserError if stdin is not available. The prompt is sent to + stdout rather than stderr. A printed between the prompt and the + input cursor. + """ + # raw_input incorrectly sends prompts to stderr, not stdout, so we + # use print_() explicitly to display prompts. + # https://bugs.python.org/issue1927 + if prompt: + print_(prompt, end=" ") + + try: + resp: str = input() + except EOFError as e: + raise UserError("stdin stream ended while input required") from e + + return resp + + +def input_options( + options: Iterable[str], + require: bool = False, + prompt: str | None = None, + fallback_prompt: str | None = None, + numrange: tuple[int, int] | None = None, + default: int | str | None = None, + max_width: int = 72, +) -> int | str: + """Prompts a user for input. The sequence of `options` defines the + choices the user has. A single-letter shortcut is inferred for each + option; the user's choice is returned as that single, lower-case + letter. The options should be provided as lower-case strings unless + a particular shortcut is desired; in that case, only that letter + should be capitalized. + + By default, the first option is the default. `default` can be provided to + override this. If `require` is provided, then there is no default. The + prompt and fallback prompt are also inferred but can be overridden. + + If numrange is provided, it is a pair of `(high, low)` (both ints) + indicating that, in addition to `options`, the user may enter an + integer in that inclusive range. + + `max_width` specifies the maximum number of columns in the + automatically generated prompt string. + """ + # Assign single letters to each option. Also capitalize the options + # to indicate the letter. + letters: dict[str, str] = {} + display_letters: list[str] = [] + capitalized: list[str] = [] + first: bool = True + option: str + for option in options: + # Is a letter already capitalized? + letter: str + found_letter: str + for letter in option: + if letter.isalpha() and letter.upper() == letter: + found_letter = letter + break + else: + # Infer a letter. + for letter in option: + if not letter.isalpha(): + continue # Don't use punctuation. + if letter not in letters: + found_letter = letter + break + else: + raise ValueError("no unambiguous lettering found") + + letters[found_letter.lower()] = option + index: int = option.index(found_letter) + + # Mark the option's shortcut letter for display. + show_letter: str + is_default: bool + if not require and ( + (default is None and not numrange and first) + or ( + isinstance(default, str) + and found_letter.lower() == default.lower() + ) + ): + # The first option is the default; mark it. + show_letter = f"[{found_letter.upper()}]" + is_default = True + else: + show_letter = found_letter.upper() + is_default = False + + # Colorize the letter shortcut. + show_letter = colorize( + "action_default" if is_default else "action", show_letter + ) + + # Insert the highlighted letter back into the word. + descr_color: ColorName = ( + "action_default" if is_default else "action_description" + ) + capitalized.append( + colorize(descr_color, option[:index]) + + show_letter + + colorize(descr_color, option[index + 1 :]) + ) + display_letters.append(found_letter.upper()) + + first = False + + # The default is just the first option if unspecified. + if require: + default = None + elif default is None: + default = numrange[0] if numrange else display_letters[0].lower() + + # Make a prompt if one is not provided. + if not prompt: + prompt_parts: list[str] = [] + prompt_part_lengths: list[int] = [] + if numrange: + if isinstance(default, int): + default_name: str = str(default) + default_name = colorize("action_default", default_name) + tmpl: str = "# selection (default {})" + prompt_parts.append(tmpl.format(default_name)) + prompt_part_lengths.append(len(tmpl) - 2 + len(str(default))) + else: + prompt_parts.append("# selection") + prompt_part_lengths.append(len(prompt_parts[-1])) + prompt_parts += capitalized + prompt_part_lengths += [len(s) for s in options] + + # Wrap the query text. + # Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow + prompt = colorize("action", "\u279c ") + line_length: int = 0 + i: int + part: str + length: int + for i, (part, length) in enumerate( + zip(prompt_parts, prompt_part_lengths) + ): + # Add punctuation. + if i == len(prompt_parts) - 1: + part += colorize("action_description", "?") + else: + part += colorize("action_description", ",") + length += 1 + + # Choose either the current line or the beginning of the next. + if line_length + length + 1 > max_width: + prompt += "\n" + line_length = 0 + + if line_length != 0: + # Not the beginning of the line; need a space. + part = f" {part}" + length += 1 + + prompt += part + line_length += length + + # Make a fallback prompt too. This is displayed if the user enters + # something that is not recognized. + if not fallback_prompt: + fallback_prompt = "Enter one of " + if numrange: + fallback_prompt += "{}-{}, ".format(*numrange) + fallback_prompt += f"{', '.join(display_letters)}:" + + resp: int | str | None = input_(prompt) + while True: + if isinstance(resp, str): + resp = resp.strip().lower() + + # Try default option. + if default is not None and not resp: + resp = default + + # Try an integer input if available. + if numrange: + try: + resp = int(resp) # type: ignore[arg-type] + except ValueError: + pass + else: + low: int + high: int + low, high = numrange + if low <= resp <= high: + return resp + else: + resp = None + + # Try a normal letter input. + if isinstance(resp, str): + resp = resp[0] + if resp in letters: + return resp + + # Prompt for new input. + resp = input_(fallback_prompt) + + +def input_yn(prompt: str | None, require: bool = False) -> bool: + """Prompts the user for a "yes" or "no" response. The default is + "yes" unless `require` is `True`, in which case there is no default. + """ + # Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow + yesno: str = colorize("action", "\u279c ") + colorize( + "action_description", "Enter Y or N:" + ) + sel: int | str = input_options(("y", "n"), require, prompt, yesno) + return sel == "y" + + +def input_select_objects( + prompt: str | None, + objs: list[T], + rep: Callable[[T], None], + prompt_all: str | None = None, +) -> list[T]: + """Prompt to user to choose all, none, or some of the given objects. + Return the list of selected objects. + + `prompt` is the prompt string to use for each question (it should be + phrased as an imperative verb). If `prompt_all` is given, it is used + instead of `prompt` for the first (yes(/no/select) question. + `rep` is a function to call on each object to print it out when confirming + objects individually. + """ + choice: int | str = input_options( + ("y", "n", "s"), False, f"{prompt_all or prompt}? (Yes/no/select)" + ) + print() # Blank line. + + if choice == "y": # Yes. + return objs + + elif choice == "s": # Select. + out: list[T] = [] + obj: T + for obj in objs: + rep(obj) + answer: int | str = input_options( + ("y", "n", "q"), + True, + f"{prompt}? (yes/no/quit)", + "Enter Y or N:", + ) + if answer == "y": + out.append(obj) + elif answer == "q": + return out + return out + + else: # No. + return [] + + +def get_path_formats( + subview: confuse.Subview | None = None, +) -> list[tuple[str, Template]]: + """Get the configuration's path formats as a list of query/template + pairs. + """ + path_formats: list[tuple[str, Template]] = [] + subview = subview or config["paths"] + query: str + view: confuse.Subview + for query, view in subview.items(): + query = PF_KEY_QUERIES.get(query, query) # Expand common queries. + path_formats.append((query, template(view.as_str()))) + return path_formats + + +def get_replacements() -> list[tuple[re.Pattern[str], str]]: + """Confuse validation function that reads regex/string pairs.""" + replacements: list[tuple[re.Pattern[str], str]] = [] + pattern: str + repl: str + for pattern, repl in config["replace"].get(dict).items(): + repl = repl or "" + try: + replacements.append((re.compile(pattern), repl)) + except re.error: + raise UserError( + f"malformed regular expression in replace: {pattern}" + ) + return replacements + + +def term_width() -> int: + """Get the width (columns) of the terminal.""" + fallback: int = config["ui"]["terminal_width"].get(int) + + # The fcntl and termios modules are not available on non-Unix + # platforms, so we fall back to a constant. + try: + import fcntl + import termios + except ImportError: + return fallback + + width: int + try: + buf: bytes = fcntl.ioctl(0, termios.TIOCGWINSZ, b" " * 4) + except OSError: + return fallback + try: + _, width = struct.unpack("hh", buf) + except struct.error: + return fallback + return width + + +def split_into_lines(string: str, width_tuple: tuple[int, int, int]): + """Splits string into a list of substrings at whitespace. + + `width_tuple` is a 3-tuple of `(first_width, last_width, middle_width)`. + The first substring has a length not longer than `first_width`, the last + substring has a length not longer than `last_width`, and all other + substrings have a length not longer than `middle_width`. + `string` may contain ANSI codes at word borders. + """ + first_width, middle_width, last_width = width_tuple + words: list[str] + + if uncolorize(string) == string: + # No colors in string + words = string.split() + else: + # Use a regex to find escapes and the text within them. + words = [] + m: re.Match[str] + for m in ESC_TEXT_REGEX.finditer(string): + # m contains four groups: + # pretext - any text before escape sequence + # esc - intitial escape sequence + # text - text, no escape sequence, may contain spaces + # reset - ASCII colour reset + space_before_text: bool = False + if m.group("pretext") != "": + # Some pretext found, let's handle it + # Add any words in the pretext + words += m.group("pretext").split() + if m.group("pretext")[-1] == " ": + # Pretext ended on a space + space_before_text = True + else: + # Pretext ended mid-word, ensure next word + pass + else: + # pretext empty, treat as if there is a space before + space_before_text = True + if m.group("text")[0] == " ": + # First character of the text is a space + space_before_text = True + # Now, handle the words in the main text: + raw_words: list[str] = m.group("text").split() + if space_before_text: + # Colorize each word with pre/post escapes + # Reconstruct colored words + words += [ + f"{m['esc']}{raw_word}{RESET_COLOR}" + for raw_word in raw_words + ] + elif raw_words: + # Pretext stops mid-word + if m.group("esc") != RESET_COLOR: + # Add the rest of the current word, with a reset after it + words[-1] += f"{m['esc']}{raw_words[0]}{RESET_COLOR}" + # Add the subsequent colored words: + words += [ + f"{m['esc']}{raw_word}{RESET_COLOR}" + for raw_word in raw_words[1:] + ] + else: + # Caught a mid-word escape sequence + words[-1] += raw_words[0] + words += raw_words[1:] + if ( + m.group("text")[-1] != " " + and m.group("posttext") != "" + and m.group("posttext")[0] != " " + ): + # reset falls mid-word + post_text: list[str] = m.group("posttext").split() + words[-1] += post_text[0] + words += post_text[1:] + else: + # Add any words after escape sequence + words += m.group("posttext").split() + result: list[str] = [] + next_substr: str = str("") + # Iterate over all words. + previous_fit: bool = False + i: int + for i in range(len(words)): # pyrefly: ignore[bad-assignment] + pot_substr: str = ( # (optimistically) add the next word to check the fit + words[i] if i == 0 else " ".join([next_substr, words[i]]) + ) + # Find out if the pot(ential)_substr fits into the next substring. + fits_first: bool = ( + len(result) == 0 and color_len(pot_substr) <= first_width + ) + fits_middle: bool = ( + len(result) != 0 and color_len(pot_substr) <= middle_width + ) + if fits_first or fits_middle: + # Fitted(!) let's try and add another word before appending + next_substr = pot_substr + previous_fit = True + elif not fits_first and not fits_middle and previous_fit: + # Extra word didn't fit, append what we have + result.append(next_substr) + next_substr = words[i] + previous_fit = color_len(next_substr) <= middle_width + else: + # Didn't fit anywhere + if uncolorize(pot_substr) == pot_substr: + # Simple uncolored string, append a cropped word + if len(result) == 0: + # Crop word by the first_width for the first line + result.append(pot_substr[:first_width]) + # add rest of word to next line + next_substr = pot_substr[first_width:] + else: + result.append(pot_substr[:middle_width]) + next_substr = pot_substr[middle_width:] + else: + # Colored strings + if len(result) == 0: + this_line, next_line = color_split(pot_substr, first_width) + result.append(this_line) + next_substr = next_line + else: + this_line, next_line = color_split(pot_substr, middle_width) + result.append(this_line) + next_substr = next_line + previous_fit = color_len(next_substr) <= middle_width + + # We finished constructing the substrings, but the last substring + # has not yet been added to the result. + result.append(next_substr) + # Also, the length of the last substring was only checked against + # `middle_width`. Append an empty substring as the new last substring if + # the last substring is too long. + if not color_len(next_substr) <= last_width: + result.append("") + return result + + +def print_column_layout( + indent_str: str, + left: ColumnLayout, + right: ColumnLayout, + separator: str = " -> ", + max_width: int = term_width(), +) -> None: + """Print left & right data, with separator inbetween + 'left' and 'right' have a structure of: + {'prefix':u'','contents':u'','suffix':u'','width':0} + In a column layout the printing will be: + {indent_str}{lhs0}{separator}{rhs0} + {lhs1 / padding }{rhs1} + ... + The first line of each column (i.e. {lhs0} or {rhs0}) is: + {prefix}{part of contents}{suffix} + With subsequent lines (i.e. {lhs1}, {rhs1} onwards) being the + rest of contents, wrapped if the width would be otherwise exceeded. + """ + if not any((right["prefix"], right["contents"], right["suffix"])): + # No right hand information, so we don't need a separator. + separator = "" + first_line_no_wrap: str = ( + f"{indent_str}{left['prefix']}{left['contents']}{left['suffix']}" + f"{separator}{right['prefix']}{right['contents']}{right['suffix']}" + ) + if color_len(first_line_no_wrap) < max_width: + # Everything fits, print out line. + print_(first_line_no_wrap) + else: + # Wrap into columns + if "width" not in left or "width" not in right: + # If widths have not been defined, set to share space. + left["width"] = ( + max_width - len(indent_str) - color_len(separator) + ) // 2 + right["width"] = ( + max_width - len(indent_str) - color_len(separator) + ) // 2 + # On the first line, account for suffix as well as prefix + left_width_tuple: tuple[int, int, int] = ( + left["width"] + - color_len(left["prefix"]) + - color_len(left["suffix"]), + left["width"] - color_len(left["prefix"]), + left["width"] - color_len(left["prefix"]), + ) + + left_split: list[str] = split_into_lines( + left["contents"], left_width_tuple + ) + right_width_tuple: tuple[int, int, int] = ( + right["width"] + - color_len(right["prefix"]) + - color_len(right["suffix"]), + right["width"] - color_len(right["prefix"]), + right["width"] - color_len(right["prefix"]), + ) + + right_split: list[str] = split_into_lines( + right["contents"], right_width_tuple + ) + max_line_count: int = max(len(left_split), len(right_split)) + + out: str = "" + i: int + for i in range(max_line_count): + # indentation + out += indent_str + + # Prefix or indent_str for line + out += ( + left["prefix"] if i == 0 else indent(color_len(left["prefix"])) + ) + + # Line i of left hand side contents. + left_part_len: int + if i < len(left_split): + out += left_split[i] + left_part_len = color_len(left_split[i]) + else: + left_part_len = 0 + + # Padding until end of column. + # Note: differs from original + # column calcs in not -1 afterwards for space + # in track number as that is included in 'prefix' + padding: int = ( + left["width"] - color_len(left["prefix"]) - left_part_len + ) + + # Remove some padding on the first line to display + # length + if i == 0: + padding -= color_len(left["suffix"]) + + out += indent(padding) + + if i == 0: + out += left["suffix"] + + # Separator between columns. + out += separator if i == 0 else indent(color_len(separator)) + + # Right prefix, contents, padding, suffix + out += ( + right["prefix"] + if i == 0 + else indent(color_len(right["prefix"])) + ) + + # Line i of right hand side. + right_part_len: int + if i < len(right_split): + out += right_split[i] + right_part_len = color_len(right_split[i]) + else: + right_part_len = 0 + + # Padding until end of column + padding = ( + right["width"] - color_len(right["prefix"]) - right_part_len + ) + # Remove some padding on the first line to display + # length + if i == 0: + padding -= color_len(right["suffix"]) + out += indent(padding) + # Length in first line + if i == 0: + out += right["suffix"] + + # Linebreak, except in the last line. + if i < max_line_count - 1: + out += "\n" + + # Constructed all of the columns, now print + print_(out) + + +def print_newline_layout( + indent_str: str, + left: ColumnLayout, + right: ColumnLayout, + separator: str = " -> ", + max_width: int = term_width(), +) -> None: + """Prints using a newline separator between left & right if + they go over their allocated widths. The datastructures are + shared with the column layout. In contrast to the column layout, + the prefix and suffix are printed at the beginning and end of + the contents. If no wrapping is required (i.e. everything fits) the + first line will look exactly the same as the column layout: + {indent}{lhs0}{separator}{rhs0} + However if this would go over the width given, the layout now becomes: + {indent}{lhs0} + {indent}{separator}{rhs0} + If {lhs0} would go over the maximum width, the subsequent lines are + indented a second time for ease of reading. + """ + if f"{right['prefix']}{right['contents']}{right['suffix']}" == "": + # No right hand information, so we don't need a separator. + separator = "" + first_line_no_wrap: str = ( + f"{indent_str}{left['prefix']}{left['contents']}{left['suffix']}" + f"{separator}{right['prefix']}{right['contents']}{right['suffix']}" + ) + if color_len(first_line_no_wrap) < max_width: + # Everything fits, print out line. + print_(first_line_no_wrap) + else: + # Newline separation, with wrapping + empty_space: int = max_width - len(indent_str) + # On lower lines we will double the indent for clarity + left_width_tuple: tuple[int, int, int] = ( + empty_space, + empty_space - len(indent_str), + empty_space - len(indent_str), + ) + left_str: str = f"{left['prefix']}{left['contents']}{left['suffix']}" + left_split: list[str] = split_into_lines(left_str, left_width_tuple) + # Repeat calculations for rhs, including separator on first line + right_width_tuple = ( + empty_space - color_len(separator), + empty_space - len(indent_str), + empty_space - len(indent_str), + ) + right_str: str = ( + f"{right['prefix']}{right['contents']}{right['suffix']}" + ) + right_split: list[str] = split_into_lines(right_str, right_width_tuple) + i: int + line: str + for i, line in enumerate(left_split): + if i == 0: + print_(f"{indent_str}{line}") + elif line != "": + # Ignore empty lines + print_(f"{indent_str * 2}{line}") + for i, line in enumerate(right_split): + if i == 0: + print_(f"{indent_str}{separator}{line}") + elif line != "": + print_(f"{indent_str * 2}{line}") + + +FLOAT_EPSILON: Final = 0.01 + + +def _field_diff( + field: str, + old: library.LibModel, + old_fmt: db.FormattedMapping | None, + new: library.LibModel, + new_fmt: db.FormattedMapping, +) -> str | None: + """Given two Model objects and their formatted views, format their values + for `field` and highlight changes among them. Return a human-readable + string. If the value has not changed, return None instead. + """ + oldval = old.get(field) + newval = new.get(field) + + # If no change, abort. + if ( + isinstance(oldval, float) + and isinstance(newval, float) + and abs(oldval - newval) < FLOAT_EPSILON + ): + return None + elif oldval == newval: + return None + + # Get formatted values for output. + oldstr: str = old_fmt.get(field, "") if old_fmt else "" + newstr: str = new_fmt.get(field, "") + + # For strings, highlight changes. For others, colorize the whole + # thing. + if isinstance(oldval, str): + oldstr, newstr = colordiff(oldval, newstr) + else: + oldstr = colorize("text_diff_removed", oldstr) + newstr = colorize("text_diff_added", newstr) + + return f"{oldstr} -> {newstr}" + + +def show_model_changes( + new: library.LibModel, + old: library.LibModel | None = None, + fields: Iterable[str] | None = None, + always: bool = False, + print_obj: bool = True, +) -> bool: + """Given a Model object, print a list of changes from its pristine + version stored in the database. Return a boolean indicating whether + any changes were found. + + `old` may be the "original" object to avoid using the pristine + version from the database. `fields` may be a list of fields to + restrict the detection to. `always` indicates whether the object is + always identified, regardless of whether any changes are present. + """ + if not old and new._db: + old = new._db._get(type(new), new.id) + + # Keep the formatted views around instead of re-creating them in each + # iteration step + old_fmt: db.FormattedMapping | None = old.formatted() if old else None + new_fmt: db.FormattedMapping = new.formatted() + + # Build up lines showing changed fields + field: str + changes: list[str] = [] + if old: + for field in old: + # Subset of the fields. Never show mtime. + if field == "mtime" or (fields and field not in fields): + continue + + # Detect and show difference for this field. + line: str | None = _field_diff(field, old, old_fmt, new, new_fmt) + if line: + changes.append(f" {field}: {line}") + + # New fields. + for field in set(new) - set(old or ()): + if fields and field not in fields: + continue + + changes.append( + f" {field}: {colorize('text_highlight', new_fmt[field])}" + ) + + # Print changes. + if print_obj and (changes or always): + print_(format(old)) + if changes: + print_("\n".join(changes)) + + return bool(changes) + + +def show_path_changes( + path_changes: Iterable[tuple[util.PathLike, util.PathLike]], +) -> None: + """Given a list of tuples (source, destination) that indicate the + path changes, log the changes as INFO-level output to the beets log. + The output is guaranteed to be unicode. + + Every pair is shown on a single line if the terminal width permits it, + else it is split over two lines. E.g., + + Source -> Destination + + vs. + + Source + -> Destination + """ + sources: Iterable[bytes | str] + destinations: Iterable[bytes | str] + sources, destinations = zip(*path_changes) + + # Ensure unicode output + sources = list(map(util.displayable_path, sources)) + destinations = list(map(util.displayable_path, destinations)) + + # Calculate widths for terminal split + col_width: int = (term_width() - len(" -> ")) // 2 + max_width: int = len(max([sources + destinations], key=len)) + + source: bytes | str + dest: bytes | str + if max_width > col_width: + # Print every change over two lines + for source, dest in zip(sources, destinations): + color_source: str + color_dest: str + color_source, color_dest = colordiff(source, dest) + print_(f"{color_source} \n -> {color_dest}") + else: + # Print every change on a single line, and add a header + title_pad: int = max_width - len("Source ") + len(" -> ") + + print_(f"Source {' ' * title_pad} Destination") + for source, dest in zip(sources, destinations): + pad: int = max_width - len(source) + color_source, color_dest = colordiff(source, dest) + print_(f"{color_source} {' ' * pad} -> {color_dest}") + + +# Helper functions for option parsing. + + +def _store_dict( + option: optparse.Option, + opt_str: str, + value: str, + parser: optparse.OptionParser, +) -> None: + """Custom action callback to parse options which have ``key=value`` + pairs as values. All such pairs passed for this option are + aggregated into a dictionary. + """ + dest: str = option.dest or "" + option_values: dict[str, str] | None = getattr(parser.values, dest, None) + + if option_values is None: + # This is the first supplied ``key=value`` pair of option. + # Initialize empty dictionary and get a reference to it. + setattr(parser.values, dest, {}) + option_values = getattr(parser.values, dest) + + try: + key: str + key, value = value.split("=", 1) + if not (key and value): + raise ValueError + except ValueError: + raise UserError( + f"supplied argument `{value}' is not of the form `key=value'" + ) + + option_values[key] = value + + +optparse.Option.ALWAYS_TYPED_ACTIONS += ("callback",) + + +class CommonOptionsParser(optparse.OptionParser): + """Offers a simple way to add common formatting options. + + Options available include: + - matching albums instead of tracks: add_album_option() + - showing paths instead of items/albums: add_path_option() + - changing the format of displayed items/albums: add_format_option() + + The last one can have several behaviors: + - against a special target + - with a certain format + - autodetected target with the album option + + Each method is fully documented in the related method. + """ + + def __init__( + self, + **kwargs: Unpack[OptionParserParams], + ) -> None: + super().__init__(**kwargs) + self._album_flags: set[str] | None = None + # this serves both as an indicator that we offer the feature AND allows + # us to check whether it has been specified on the CLI - bypassing the + # fact that arguments may be in any order + + def add_album_option( + self, flags: tuple[str, str] = ("-a", "--album") + ) -> None: + """Add a -a/--album option to match albums instead of tracks. + + If used then the format option can auto-detect whether we're setting + the format for items or albums. + Sets the album property on the options extracted from the CLI. + """ + album: optparse.Option = optparse.Option( + *flags, action="store_true", help="match albums instead of tracks" + ) + self.add_option(album) + self._album_flags = set(flags) + + def _set_format( + self, + option: optparse.Option, + opt_str: str, + value: str, + parser: CommonOptionsParser, + target: type[library.Album | library.Item] | None = None, + fmt: str | None = None, + store_true: bool = False, + ) -> None: + """Internal callback that sets the correct format while parsing CLI + arguments. + """ + if store_true and option.dest: + setattr(parser.values, option.dest, True) + + # Use the explicitly specified format, or the string from the option. + value = fmt or value or "" + if parser.values is None: + parser.values = optparse.Values() + parser.values.format = value + + if target: + config[target._format_config_key].set(value) + return + + if self._album_flags: + if parser.values.album: + target = library.Album + else: + # the option is either missing either not parsed yet + if self._album_flags & set(parser.rargs or ()): + target = library.Album + else: + target = library.Item + config[target._format_config_key].set(value) + else: + config[library.Item._format_config_key].set(value) + config[library.Album._format_config_key].set(value) + + def add_path_option( + self, flags: tuple[str, str] = ("-p", "--path") + ) -> None: + """Add a -p/--path option to display the path instead of the default + format. + + By default this affects both items and albums. If add_album_option() + is used then the target will be autodetected. + + Sets the format property to '$path' on the options extracted from the + CLI. + """ + path: optparse.Option = optparse.Option( + *flags, + nargs=0, + action="callback", + callback=self._set_format, + callback_kwargs={"fmt": "$path", "store_true": True}, + help="print paths for matched items or albums", + ) + self.add_option(path) + + def add_format_option( + self, + flags: tuple[str, ...] = ("-f", "--format"), + target: str | type[library.LibModel] | None = None, + ) -> None: + """Add -f/--format option to print some LibModel instances with a + custom format. + + `target` is optional and can be one of ``library.Item``, 'item', + ``library.Album`` and 'album'. + + Several behaviors are available: + - if `target` is given then the format is only applied to that + LibModel + - if the album option is used then the target will be autodetected + - otherwise the format is applied to both items and albums. + + Sets the format property on the options extracted from the CLI. + """ + kwargs: dict[str, type[library.LibModel]] = {} + if target: + if isinstance(target, str): + target = {"item": library.Item, "album": library.Album}[target] + kwargs["target"] = target + + opt: optparse.Option = optparse.Option( + *flags, + action="callback", + callback=self._set_format, + callback_kwargs=kwargs, + help="print with custom format", + ) + self.add_option(opt) + return None + + def add_all_common_options(self) -> None: + """Add album, path and format options.""" + self.add_album_option() + self.add_path_option() + self.add_format_option() + + +# Subcommand parsing infrastructure. +# +# This is a fairly generic subcommand parser for optparse. It is +# maintained externally here: +# https://gist.github.com/462717 +# There you will also find a better description of the code and a more +# succinct example program. + + +class Subcommand: + """A subcommand of a root command-line application that may be + invoked by a SubcommandOptionParser. + """ + + def __init__( + self, + name: str, + func: ( + Callable[ + [library.Library, optparse.Values, list[str] | tuple[str]], + Any, + ] + ) + | None = None, + parser: CommonOptionsParser | None = None, + help: str = "", + aliases: tuple[str, ...] = (), + hide: bool = False, + ) -> None: + """Creates a new subcommand. name is the primary way to invoke + the subcommand; aliases are alternate names. parser is an + OptionParser responsible for parsing the subcommand's options. + help is a short description of the command. If no parser is + given, it defaults to a new, empty CommonOptionsParser. + """ + self.name: str = name + self.func: Callable[ + [library.Library, optparse.Values, list[str] | tuple[str]], Any + ] + if func: + self.func = func + + self.parser: CommonOptionsParser = parser or CommonOptionsParser() + self.aliases: tuple[str, ...] = aliases + self.help: str = help + self.hide: bool = hide + self._root_parser: optparse.OptionParser | None = None + + def print_help(self) -> None: + self.parser.print_help() + + def parse_args(self, args: list[str]) -> tuple[optparse.Values, list[str]]: + return self.parser.parse_args(args) + + @property + def root_parser(self) -> optparse.OptionParser | None: + return self._root_parser + + @root_parser.setter + def root_parser(self, root_parser: optparse.OptionParser) -> None: + self._root_parser = root_parser + self.parser.prog = ( + f"{as_string(root_parser.get_prog_name())} {self.name}" + ) + + +class SubcommandsOptionParser(CommonOptionsParser): + """A variant of OptionParser that parses subcommands and their + arguments. + """ + + def __init__(self, **kwargs: Unpack[OptionParserParams]) -> None: + """Create a new subcommand-aware option parser. All of the + options to OptionParser.__init__ are supported in addition + to subcommands, a sequence of Subcommand objects. + """ + # A more helpful default usage. + if "usage" not in kwargs: + kwargs["usage"] = """ + %prog COMMAND [ARGS...] + %prog help COMMAND""" + kwargs["add_help_option"] = False + + # Super constructor. + super().__init__(**kwargs) + + # Our root parser needs to stop on the first unrecognized argument. + self.disable_interspersed_args() + + self.subcommands: list[Subcommand] = [] + + def add_subcommand(self, *cmds: Subcommand) -> None: + """Adds a Subcommand object to the parser's list of commands.""" + for cmd in cmds: + cmd.root_parser = self + self.subcommands.append(cmd) + + # Add the list of subcommands to the help message. + @override + def format_help( + self, formatter: optparse.HelpFormatter | None = None + ) -> str: + # Get the original help message, to which we will append. + out: str = super().format_help(formatter) + if formatter is None: + formatter = self.formatter + + # Subcommands header. + result: list[str] = ["\n" + formatter.format_heading("Commands")] + formatter.indent() + + # Generate the display names (including aliases). + # Also determine the help position. + disp_names: list[str] = [] + help_position: int = 0 + subcommands: list[Subcommand] = [ + c for c in self.subcommands if not c.hide + ] + subcommands.sort(key=lambda c: c.name) + name: str + subcommand: Subcommand + for subcommand in subcommands: + name = subcommand.name + if subcommand.aliases: + name += f" ({', '.join(subcommand.aliases)})" + disp_names.append(name) + + # Set the help position based on the max width. + proposed_help_position: int = ( + len(name) + formatter.current_indent + 2 + ) + if proposed_help_position <= formatter.max_help_position: + help_position = max(help_position, proposed_help_position) + + # Add each subcommand to the output. + for subcommand, name in zip(subcommands, disp_names): + # Lifted directly from optparse.py. + name_width: int = help_position - formatter.current_indent - 2 + indent_first: int + if len(name) > name_width: + name = f"{' ' * formatter.current_indent}{name}\n" + indent_first = help_position + else: + name = f"{' ' * formatter.current_indent}{name:<{name_width}}\n" + indent_first = 0 + result.append(name) + help_width: int = formatter.width - help_position + help_lines: list[str] = textwrap.wrap(subcommand.help, help_width) + help_line: str = help_lines[0] if help_lines else "" + result.append(f"{' ' * indent_first}{help_line}\n") + result.extend( + [f"{' ' * help_position}{line}\n" for line in help_lines[1:]] + ) + formatter.dedent() + + # Concatenate the original help message with the subcommand + # list. + return f"{out}{''.join(result)}" + + def _subcommand_for_name(self, name: str) -> Subcommand | None: + """Return the subcommand in self.subcommands matching the + given name. The name may either be the name of a subcommand or + an alias. If no subcommand matches, returns None. + """ + return next( + ( + subcommand + for subcommand in self.subcommands + if name == subcommand.name or name in subcommand.aliases + ), + None, + ) + + def parse_global_options(self, args: list[str]): + """Parse options up to the subcommand argument. Returns a tuple + of the options object and the remaining arguments. + """ + options: optparse.Values + subargs: list[str] + options, subargs = self.parse_args(args) + + # Force the help command + if options.help: + subargs = ["help"] + elif options.version: + subargs = ["version"] + return options, subargs + + def parse_subcommand(self, args: list[str]): + """Given the `args` left unused by a `parse_global_options`, + return the invoked subcommand, the subcommand options, and the + subcommand arguments. + """ + # Help is default command + if not args: + args = ["help"] + + cmdname: str = args.pop(0) + subcommand: Subcommand | None = self._subcommand_for_name(cmdname) + if not subcommand: + raise UserError(f"unknown command '{cmdname}'") + + suboptions: optparse.Values + subargs: list[str] + suboptions, subargs = subcommand.parse_args(args) + return subcommand, suboptions, subargs diff --git a/beetsplug/edit.py b/beetsplug/edit.py index f6fadefd0c..7257538381 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -25,7 +25,8 @@ from beets import plugins, ui, util from beets.dbcore import types from beets.importer import Action -from beets.ui.commands import PromptChoice, _do_query +from beets.ui.commands._utils import do_query +from beets.ui.commands.import_.session 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. @@ -176,7 +177,7 @@ def commands(self): def _edit_command(self, lib, opts, args): """The CLI command function for the `beet edit` command.""" # Get the objects to edit. - items, albums = _do_query(lib, args, opts.album, False) + items, albums = do_query(lib, args, opts.album, False) objs = albums if opts.album else items if not objs: ui.print_("Nothing to edit.") diff --git a/docs/changelog.rst b/docs/changelog.rst index cce30a2844..9b81d1488e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,6 +21,10 @@ For packagers: Other changes: +- Refactored the ``beets/ui/commands.py`` monolithic file (2000+ lines) into + multiple modules within the ``beets/ui/commands`` directory for better + maintainability. + 2.5.1 (October 14, 2025) ------------------------ diff --git a/test/test_plugins.py b/test/test_plugins.py index 07bbf09663..1eefea8fd6 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -272,7 +272,8 @@ def setUp(self): self.addCleanup(self.matcher.restore) # keep track of ui.input_option() calls self.input_options_patcher = patch( - "beets.ui.input_options", side_effect=ui.input_options + "beets.ui.commands.import_.session.input_options", + side_effect=ui.core.input_options, ) self.mock_input_options = self.input_options_patcher.start() diff --git a/test/test_ui.py b/test/test_ui.py deleted file mode 100644 index 534d0e4665..0000000000 --- a/test/test_ui.py +++ /dev/null @@ -1,1587 +0,0 @@ -# This file is part of beets. -# Copyright 2016, Adrian Sampson. -# -# 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 command-line interface.""" - -import os -import platform -import re -import shutil -import subprocess -import sys -import unittest -from pathlib import Path -from unittest.mock import Mock, patch - -import pytest -from confuse import ConfigError -from mediafile import MediaFile - -from beets import autotag, config, library, plugins, ui, util -from beets.autotag.match import distance -from beets.test import _common -from beets.test.helper import ( - BeetsTestCase, - IOMixin, - PluginTestCase, - capture_stdout, - control_stdin, - has_program, -) -from beets.ui import commands -from beets.util import MoveOperation, syspath - - -class ListTest(BeetsTestCase): - def setUp(self): - super().setUp() - self.item = _common.item() - self.item.path = "xxx/yyy" - self.lib.add(self.item) - self.lib.add_album([self.item]) - - def _run_list(self, query="", album=False, path=False, fmt=""): - with capture_stdout() as stdout: - commands.list_items(self.lib, query, album, fmt) - return stdout - - def test_list_outputs_item(self): - stdout = self._run_list() - assert "the title" in stdout.getvalue() - - def test_list_unicode_query(self): - self.item.title = "na\xefve" - self.item.store() - self.lib._connection().commit() - - stdout = self._run_list(["na\xefve"]) - out = stdout.getvalue() - assert "na\xefve" in out - - def test_list_item_path(self): - stdout = self._run_list(fmt="$path") - assert stdout.getvalue().strip() == "xxx/yyy" - - def test_list_album_outputs_something(self): - stdout = self._run_list(album=True) - assert len(stdout.getvalue()) > 0 - - def test_list_album_path(self): - stdout = self._run_list(album=True, fmt="$path") - assert stdout.getvalue().strip() == "xxx" - - def test_list_album_omits_title(self): - stdout = self._run_list(album=True) - assert "the title" not in stdout.getvalue() - - def test_list_uses_track_artist(self): - stdout = self._run_list() - assert "the artist" in stdout.getvalue() - assert "the album artist" not in stdout.getvalue() - - def test_list_album_uses_album_artist(self): - stdout = self._run_list(album=True) - assert "the artist" not in stdout.getvalue() - assert "the album artist" in stdout.getvalue() - - def test_list_item_format_artist(self): - stdout = self._run_list(fmt="$artist") - assert "the artist" in stdout.getvalue() - - def test_list_item_format_multiple(self): - stdout = self._run_list(fmt="$artist - $album - $year") - assert "the artist - the album - 0001" == stdout.getvalue().strip() - - def test_list_album_format(self): - stdout = self._run_list(album=True, fmt="$genre") - assert "the genre" in stdout.getvalue() - assert "the album" not in stdout.getvalue() - - -class RemoveTest(IOMixin, BeetsTestCase): - def setUp(self): - super().setUp() - - # Copy a file into the library. - self.i = library.Item.from_path(self.resource_path) - self.lib.add(self.i) - self.i.move(operation=MoveOperation.COPY) - - def test_remove_items_no_delete(self): - self.io.addinput("y") - commands.remove_items(self.lib, "", False, False, False) - items = self.lib.items() - assert len(list(items)) == 0 - assert self.i.filepath.exists() - - def test_remove_items_with_delete(self): - self.io.addinput("y") - commands.remove_items(self.lib, "", False, True, False) - items = self.lib.items() - assert len(list(items)) == 0 - assert not self.i.filepath.exists() - - def test_remove_items_with_force_no_delete(self): - commands.remove_items(self.lib, "", False, False, True) - items = self.lib.items() - assert len(list(items)) == 0 - assert self.i.filepath.exists() - - def test_remove_items_with_force_delete(self): - commands.remove_items(self.lib, "", False, True, True) - items = self.lib.items() - assert len(list(items)) == 0 - assert not self.i.filepath.exists() - - def test_remove_items_select_with_delete(self): - i2 = library.Item.from_path(self.resource_path) - self.lib.add(i2) - i2.move(operation=MoveOperation.COPY) - - for s in ("s", "y", "n"): - self.io.addinput(s) - commands.remove_items(self.lib, "", False, True, False) - items = self.lib.items() - assert len(list(items)) == 1 - # There is probably no guarantee that the items are queried in any - # spcecific order, thus just ensure that exactly one was removed. - # To improve upon this, self.io would need to have the capability to - # generate input that depends on previous output. - num_existing = 0 - num_existing += 1 if os.path.exists(syspath(self.i.path)) else 0 - num_existing += 1 if os.path.exists(syspath(i2.path)) else 0 - assert num_existing == 1 - - def test_remove_albums_select_with_delete(self): - a1 = self.add_album_fixture() - a2 = self.add_album_fixture() - path1 = a1.items()[0].path - path2 = a2.items()[0].path - items = self.lib.items() - assert len(list(items)) == 3 - - for s in ("s", "y", "n"): - self.io.addinput(s) - commands.remove_items(self.lib, "", True, True, False) - items = self.lib.items() - assert len(list(items)) == 2 # incl. the item from setUp() - # See test_remove_items_select_with_delete() - num_existing = 0 - num_existing += 1 if os.path.exists(syspath(path1)) else 0 - num_existing += 1 if os.path.exists(syspath(path2)) else 0 - assert num_existing == 1 - - -class ModifyTest(BeetsTestCase): - def setUp(self): - super().setUp() - self.album = self.add_album_fixture() - [self.item] = self.album.items() - - def modify_inp(self, inp, *args): - with control_stdin(inp): - self.run_command("modify", *args) - - def modify(self, *args): - self.modify_inp("y", *args) - - # Item tests - - def test_modify_item(self): - self.modify("title=newTitle") - item = self.lib.items().get() - assert item.title == "newTitle" - - def test_modify_item_abort(self): - item = self.lib.items().get() - title = item.title - self.modify_inp("n", "title=newTitle") - item = self.lib.items().get() - assert item.title == title - - def test_modify_item_no_change(self): - title = "Tracktitle" - item = self.add_item_fixture(title=title) - self.modify_inp("y", "title", f"title={title}") - item = self.lib.items(title).get() - assert item.title == title - - def test_modify_write_tags(self): - self.modify("title=newTitle") - item = self.lib.items().get() - item.read() - assert item.title == "newTitle" - - def test_modify_dont_write_tags(self): - self.modify("--nowrite", "title=newTitle") - item = self.lib.items().get() - item.read() - assert item.title != "newTitle" - - def test_move(self): - self.modify("title=newTitle") - item = self.lib.items().get() - assert b"newTitle" in item.path - - def test_not_move(self): - self.modify("--nomove", "title=newTitle") - item = self.lib.items().get() - assert b"newTitle" not in item.path - - def test_no_write_no_move(self): - self.modify("--nomove", "--nowrite", "title=newTitle") - item = self.lib.items().get() - item.read() - assert b"newTitle" not in item.path - assert item.title != "newTitle" - - def test_update_mtime(self): - item = self.item - old_mtime = item.mtime - - self.modify("title=newTitle") - item.load() - assert old_mtime != item.mtime - assert item.current_mtime() == item.mtime - - def test_reset_mtime_with_no_write(self): - item = self.item - - self.modify("--nowrite", "title=newTitle") - item.load() - assert 0 == item.mtime - - def test_selective_modify(self): - title = "Tracktitle" - album = "album" - original_artist = "composer" - new_artist = "coverArtist" - for i in range(0, 10): - self.add_item_fixture( - title=f"{title}{i}", artist=original_artist, album=album - ) - self.modify_inp( - "s\ny\ny\ny\nn\nn\ny\ny\ny\ny\nn", title, f"artist={new_artist}" - ) - original_items = self.lib.items(f"artist:{original_artist}") - new_items = self.lib.items(f"artist:{new_artist}") - assert len(list(original_items)) == 3 - assert len(list(new_items)) == 7 - - def test_modify_formatted(self): - for i in range(0, 3): - self.add_item_fixture( - title=f"title{i}", artist="artist", album="album" - ) - items = list(self.lib.items()) - self.modify("title=${title} - append") - for item in items: - orig_title = item.title - item.load() - assert item.title == f"{orig_title} - append" - - # Album Tests - - def test_modify_album(self): - self.modify("--album", "album=newAlbum") - album = self.lib.albums().get() - assert album.album == "newAlbum" - - def test_modify_album_write_tags(self): - self.modify("--album", "album=newAlbum") - item = self.lib.items().get() - item.read() - assert item.album == "newAlbum" - - def test_modify_album_dont_write_tags(self): - self.modify("--album", "--nowrite", "album=newAlbum") - item = self.lib.items().get() - item.read() - assert item.album == "the album" - - def test_album_move(self): - self.modify("--album", "album=newAlbum") - item = self.lib.items().get() - item.read() - assert b"newAlbum" in item.path - - def test_album_not_move(self): - self.modify("--nomove", "--album", "album=newAlbum") - item = self.lib.items().get() - item.read() - assert b"newAlbum" not in item.path - - def test_modify_album_formatted(self): - item = self.lib.items().get() - orig_album = item.album - self.modify("--album", "album=${album} - append") - item.load() - assert item.album == f"{orig_album} - append" - - # Misc - - def test_write_initial_key_tag(self): - self.modify("initial_key=C#m") - item = self.lib.items().get() - mediafile = MediaFile(syspath(item.path)) - assert mediafile.initial_key == "C#m" - - def test_set_flexattr(self): - self.modify("flexattr=testAttr") - item = self.lib.items().get() - assert item.flexattr == "testAttr" - - def test_remove_flexattr(self): - item = self.lib.items().get() - item.flexattr = "testAttr" - item.store() - - self.modify("flexattr!") - item = self.lib.items().get() - assert "flexattr" not in item - - @unittest.skip("not yet implemented") - def test_delete_initial_key_tag(self): - item = self.lib.items().get() - item.initial_key = "C#m" - item.write() - item.store() - - mediafile = MediaFile(syspath(item.path)) - assert mediafile.initial_key == "C#m" - - self.modify("initial_key!") - mediafile = MediaFile(syspath(item.path)) - assert mediafile.initial_key is None - - def test_arg_parsing_colon_query(self): - (query, mods, dels) = commands.modify_parse_args( - ["title:oldTitle", "title=newTitle"] - ) - assert query == ["title:oldTitle"] - assert mods == {"title": "newTitle"} - - def test_arg_parsing_delete(self): - (query, mods, dels) = commands.modify_parse_args( - ["title:oldTitle", "title!"] - ) - assert query == ["title:oldTitle"] - assert dels == ["title"] - - def test_arg_parsing_query_with_exclaimation(self): - (query, mods, dels) = commands.modify_parse_args( - ["title:oldTitle!", "title=newTitle!"] - ) - assert query == ["title:oldTitle!"] - assert mods == {"title": "newTitle!"} - - def test_arg_parsing_equals_in_value(self): - (query, mods, dels) = commands.modify_parse_args( - ["title:foo=bar", "title=newTitle"] - ) - assert query == ["title:foo=bar"] - assert mods == {"title": "newTitle"} - - -class WriteTest(BeetsTestCase): - def write_cmd(self, *args): - return self.run_with_output("write", *args) - - def test_update_mtime(self): - item = self.add_item_fixture() - item["title"] = "a new title" - item.store() - - item = self.lib.items().get() - assert item.mtime == 0 - - self.write_cmd() - item = self.lib.items().get() - assert item.mtime == item.current_mtime() - - def test_non_metadata_field_unchanged(self): - """Changing a non-"tag" field like `bitrate` and writing should - have no effect. - """ - # An item that starts out "clean". - item = self.add_item_fixture() - item.read() - - # ... but with a mismatched bitrate. - item.bitrate = 123 - item.store() - - output = self.write_cmd() - - assert output == "" - - def test_write_metadata_field(self): - item = self.add_item_fixture() - item.read() - old_title = item.title - - item.title = "new title" - item.store() - - output = self.write_cmd() - - assert f"{old_title} -> new title" in output - - -class MoveTest(BeetsTestCase): - def setUp(self): - super().setUp() - - self.initial_item_path = self.lib_path / "srcfile" - shutil.copy(self.resource_path, self.initial_item_path) - - # Add a file to the library but don't copy it in yet. - self.i = library.Item.from_path(self.initial_item_path) - self.lib.add(self.i) - self.album = self.lib.add_album([self.i]) - - # Alternate destination directory. - self.otherdir = self.temp_dir_path / "testotherdir" - - def _move( - self, - query=(), - dest=None, - copy=False, - album=False, - pretend=False, - export=False, - ): - commands.move_items( - self.lib, dest, query, copy, album, pretend, export=export - ) - - def test_move_item(self): - self._move() - self.i.load() - assert b"libdir" in self.i.path - assert self.i.filepath.exists() - assert not self.initial_item_path.exists() - - def test_copy_item(self): - self._move(copy=True) - self.i.load() - assert b"libdir" in self.i.path - assert self.i.filepath.exists() - assert self.initial_item_path.exists() - - def test_move_album(self): - self._move(album=True) - self.i.load() - assert b"libdir" in self.i.path - assert self.i.filepath.exists() - assert not self.initial_item_path.exists() - - def test_copy_album(self): - self._move(copy=True, album=True) - self.i.load() - assert b"libdir" in self.i.path - assert self.i.filepath.exists() - assert self.initial_item_path.exists() - - def test_move_item_custom_dir(self): - self._move(dest=self.otherdir) - self.i.load() - assert b"testotherdir" in self.i.path - assert self.i.filepath.exists() - assert not self.initial_item_path.exists() - - def test_move_album_custom_dir(self): - self._move(dest=self.otherdir, album=True) - self.i.load() - assert b"testotherdir" in self.i.path - assert self.i.filepath.exists() - assert not self.initial_item_path.exists() - - def test_pretend_move_item(self): - self._move(dest=self.otherdir, pretend=True) - self.i.load() - assert self.i.filepath == self.initial_item_path - - def test_pretend_move_album(self): - self._move(album=True, pretend=True) - self.i.load() - assert self.i.filepath == self.initial_item_path - - def test_export_item_custom_dir(self): - self._move(dest=self.otherdir, export=True) - self.i.load() - assert self.i.filepath == self.initial_item_path - assert self.otherdir.exists() - - def test_export_album_custom_dir(self): - self._move(dest=self.otherdir, album=True, export=True) - self.i.load() - assert self.i.filepath == self.initial_item_path - assert self.otherdir.exists() - - def test_pretend_export_item(self): - self._move(dest=self.otherdir, pretend=True, export=True) - self.i.load() - assert self.i.filepath == self.initial_item_path - assert not self.otherdir.exists() - - -class UpdateTest(IOMixin, BeetsTestCase): - def setUp(self): - super().setUp() - - # Copy a file into the library. - item_path = os.path.join(_common.RSRC, b"full.mp3") - item_path_two = os.path.join(_common.RSRC, b"full.flac") - self.i = library.Item.from_path(item_path) - self.i2 = library.Item.from_path(item_path_two) - self.lib.add(self.i) - self.lib.add(self.i2) - self.i.move(operation=MoveOperation.COPY) - self.i2.move(operation=MoveOperation.COPY) - self.album = self.lib.add_album([self.i, self.i2]) - - # Album art. - artfile = os.path.join(self.temp_dir, b"testart.jpg") - _common.touch(artfile) - self.album.set_art(artfile) - self.album.store() - util.remove(artfile) - - def _update( - self, - query=(), - album=False, - move=False, - reset_mtime=True, - fields=None, - exclude_fields=None, - ): - self.io.addinput("y") - if reset_mtime: - self.i.mtime = 0 - self.i.store() - commands.update_items( - self.lib, - query, - album, - move, - False, - fields=fields, - exclude_fields=exclude_fields, - ) - - def test_delete_removes_item(self): - assert list(self.lib.items()) - util.remove(self.i.path) - util.remove(self.i2.path) - self._update() - assert not list(self.lib.items()) - - def test_delete_removes_album(self): - assert self.lib.albums() - util.remove(self.i.path) - util.remove(self.i2.path) - self._update() - assert not self.lib.albums() - - def test_delete_removes_album_art(self): - art_filepath = self.album.art_filepath - assert art_filepath.exists() - util.remove(self.i.path) - util.remove(self.i2.path) - self._update() - assert not art_filepath.exists() - - def test_modified_metadata_detected(self): - mf = MediaFile(syspath(self.i.path)) - mf.title = "differentTitle" - mf.save() - self._update() - item = self.lib.items().get() - assert item.title == "differentTitle" - - def test_modified_metadata_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.title = "differentTitle" - mf.save() - self._update(move=True) - item = self.lib.items().get() - assert b"differentTitle" in item.path - - def test_modified_metadata_not_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.title = "differentTitle" - mf.save() - self._update(move=False) - item = self.lib.items().get() - assert b"differentTitle" not in item.path - - def test_selective_modified_metadata_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.title = "differentTitle" - mf.genre = "differentGenre" - mf.save() - self._update(move=True, fields=["title"]) - item = self.lib.items().get() - assert b"differentTitle" in item.path - assert item.genre != "differentGenre" - - def test_selective_modified_metadata_not_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.title = "differentTitle" - mf.genre = "differentGenre" - mf.save() - self._update(move=False, fields=["title"]) - item = self.lib.items().get() - assert b"differentTitle" not in item.path - assert item.genre != "differentGenre" - - def test_modified_album_metadata_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.album = "differentAlbum" - mf.save() - self._update(move=True) - item = self.lib.items().get() - assert b"differentAlbum" in item.path - - def test_modified_album_metadata_art_moved(self): - artpath = self.album.artpath - mf = MediaFile(syspath(self.i.path)) - mf.album = "differentAlbum" - mf.save() - self._update(move=True) - album = self.lib.albums()[0] - assert artpath != album.artpath - assert album.artpath is not None - - def test_selective_modified_album_metadata_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.album = "differentAlbum" - mf.genre = "differentGenre" - mf.save() - self._update(move=True, fields=["album"]) - item = self.lib.items().get() - assert b"differentAlbum" in item.path - assert item.genre != "differentGenre" - - def test_selective_modified_album_metadata_not_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.album = "differentAlbum" - mf.genre = "differentGenre" - mf.save() - self._update(move=True, fields=["genre"]) - item = self.lib.items().get() - assert b"differentAlbum" not in item.path - assert item.genre == "differentGenre" - - def test_mtime_match_skips_update(self): - mf = MediaFile(syspath(self.i.path)) - mf.title = "differentTitle" - mf.save() - - # Make in-memory mtime match on-disk mtime. - self.i.mtime = os.path.getmtime(syspath(self.i.path)) - self.i.store() - - self._update(reset_mtime=False) - item = self.lib.items().get() - assert item.title == "full" - - def test_multivalued_albumtype_roundtrip(self): - # https://github.com/beetbox/beets/issues/4528 - - # albumtypes is empty for our test fixtures, so populate it first - album = self.album - correct_albumtypes = ["album", "live"] - - # Setting albumtypes does not set albumtype, currently. - # Using x[0] mirrors https://github.com/beetbox/mediafile/blob/057432ad53b3b84385e5582f69f44dc00d0a725d/mediafile.py#L1928 # noqa: E501 - correct_albumtype = correct_albumtypes[0] - - album.albumtype = correct_albumtype - album.albumtypes = correct_albumtypes - album.try_sync(write=True, move=False) - - album.load() - assert album.albumtype == correct_albumtype - assert album.albumtypes == correct_albumtypes - - self._update() - - album.load() - assert album.albumtype == correct_albumtype - assert album.albumtypes == correct_albumtypes - - def test_modified_metadata_excluded(self): - mf = MediaFile(syspath(self.i.path)) - mf.lyrics = "new lyrics" - mf.save() - self._update(exclude_fields=["lyrics"]) - item = self.lib.items().get() - assert item.lyrics != "new lyrics" - - -class PrintTest(IOMixin, unittest.TestCase): - def test_print_without_locale(self): - lang = os.environ.get("LANG") - if lang: - del os.environ["LANG"] - - try: - ui.print_("something") - except TypeError: - self.fail("TypeError during print") - finally: - if lang: - os.environ["LANG"] = lang - - def test_print_with_invalid_locale(self): - old_lang = os.environ.get("LANG") - os.environ["LANG"] = "" - old_ctype = os.environ.get("LC_CTYPE") - os.environ["LC_CTYPE"] = "UTF-8" - - try: - ui.print_("something") - except ValueError: - self.fail("ValueError during print") - finally: - if old_lang: - os.environ["LANG"] = old_lang - else: - del os.environ["LANG"] - if old_ctype: - os.environ["LC_CTYPE"] = old_ctype - else: - del os.environ["LC_CTYPE"] - - -class ImportTest(BeetsTestCase): - def test_quiet_timid_disallowed(self): - config["import"]["quiet"] = True - config["import"]["timid"] = True - with pytest.raises(ui.UserError): - commands.import_files(None, [], None) - - def test_parse_paths_from_logfile(self): - if os.path.__name__ == "ntpath": - logfile_content = ( - "import started Wed Jun 15 23:08:26 2022\n" - "asis C:\\music\\Beatles, The\\The Beatles; C:\\music\\Beatles, The\\The Beatles\\CD 01; C:\\music\\Beatles, The\\The Beatles\\CD 02\n" # noqa: E501 - "duplicate-replace C:\\music\\Bill Evans\\Trio '65\n" - "skip C:\\music\\Michael Jackson\\Bad\n" - "skip C:\\music\\Soulwax\\Any Minute Now\n" - ) - expected_paths = [ - "C:\\music\\Beatles, The\\The Beatles", - "C:\\music\\Michael Jackson\\Bad", - "C:\\music\\Soulwax\\Any Minute Now", - ] - else: - logfile_content = ( - "import started Wed Jun 15 23:08:26 2022\n" - "asis /music/Beatles, The/The Beatles; /music/Beatles, The/The Beatles/CD 01; /music/Beatles, The/The Beatles/CD 02\n" # noqa: E501 - "duplicate-replace /music/Bill Evans/Trio '65\n" - "skip /music/Michael Jackson/Bad\n" - "skip /music/Soulwax/Any Minute Now\n" - ) - expected_paths = [ - "/music/Beatles, The/The Beatles", - "/music/Michael Jackson/Bad", - "/music/Soulwax/Any Minute Now", - ] - - logfile = os.path.join(self.temp_dir, b"logfile.log") - with open(logfile, mode="w") as fp: - fp.write(logfile_content) - actual_paths = list(commands._paths_from_logfile(logfile)) - assert actual_paths == expected_paths - - -@_common.slow_test() -class TestPluginTestCase(PluginTestCase): - plugin = "test" - - def setUp(self): - super().setUp() - config["pluginpath"] = [_common.PLUGINPATH] - - -class ConfigTest(TestPluginTestCase): - def setUp(self): - super().setUp() - - # Don't use the BEETSDIR from `helper`. Instead, we point the home - # directory there. Some tests will set `BEETSDIR` themselves. - del os.environ["BEETSDIR"] - - # Also set APPDATA, the Windows equivalent of setting $HOME. - appdata_dir = self.temp_dir_path / "AppData" / "Roaming" - - self._orig_cwd = os.getcwd() - self.test_cmd = self._make_test_cmd() - commands.default_commands.append(self.test_cmd) - - # Default user configuration - if platform.system() == "Windows": - self.user_config_dir = appdata_dir / "beets" - else: - self.user_config_dir = self.temp_dir_path / ".config" / "beets" - self.user_config_dir.mkdir(parents=True, exist_ok=True) - self.user_config_path = self.user_config_dir / "config.yaml" - - # Custom BEETSDIR - self.beetsdir = self.temp_dir_path / "beetsdir" - self.beetsdir.mkdir(parents=True, exist_ok=True) - - self.env_config_path = str(self.beetsdir / "config.yaml") - self.cli_config_path = str(self.temp_dir_path / "config.yaml") - self.env_patcher = patch( - "os.environ", - {"HOME": str(self.temp_dir_path), "APPDATA": str(appdata_dir)}, - ) - self.env_patcher.start() - - self._reset_config() - - def tearDown(self): - self.env_patcher.stop() - commands.default_commands.pop() - os.chdir(syspath(self._orig_cwd)) - super().tearDown() - - def _make_test_cmd(self): - test_cmd = ui.Subcommand("test", help="test") - - def run(lib, options, args): - test_cmd.lib = lib - test_cmd.options = options - test_cmd.args = args - - test_cmd.func = run - return test_cmd - - def _reset_config(self): - # Config should read files again on demand - config.clear() - config._materialized = False - - def write_config_file(self): - return open(self.user_config_path, "w") - - def test_paths_section_respected(self): - with self.write_config_file() as config: - config.write("paths: {x: y}") - - self.run_command("test", lib=None) - key, template = self.test_cmd.lib.path_formats[0] - assert key == "x" - assert template.original == "y" - - def test_default_paths_preserved(self): - default_formats = ui.get_path_formats() - - self._reset_config() - with self.write_config_file() as config: - config.write("paths: {x: y}") - self.run_command("test", lib=None) - key, template = self.test_cmd.lib.path_formats[0] - assert key == "x" - assert template.original == "y" - assert self.test_cmd.lib.path_formats[1:] == default_formats - - def test_nonexistant_db(self): - with self.write_config_file() as config: - config.write("library: /xxx/yyy/not/a/real/path") - - with pytest.raises(ui.UserError): - self.run_command("test", lib=None) - - def test_user_config_file(self): - with self.write_config_file() as file: - file.write("anoption: value") - - self.run_command("test", lib=None) - assert config["anoption"].get() == "value" - - def test_replacements_parsed(self): - with self.write_config_file() as config: - config.write("replace: {'[xy]': z}") - - self.run_command("test", lib=None) - replacements = self.test_cmd.lib.replacements - repls = [(p.pattern, s) for p, s in replacements] # Compare patterns. - assert repls == [("[xy]", "z")] - - def test_multiple_replacements_parsed(self): - with self.write_config_file() as config: - config.write("replace: {'[xy]': z, foo: bar}") - self.run_command("test", lib=None) - replacements = self.test_cmd.lib.replacements - repls = [(p.pattern, s) for p, s in replacements] - assert repls == [("[xy]", "z"), ("foo", "bar")] - - def test_cli_config_option(self): - with open(self.cli_config_path, "w") as file: - file.write("anoption: value") - self.run_command("--config", self.cli_config_path, "test", lib=None) - assert config["anoption"].get() == "value" - - def test_cli_config_file_overwrites_user_defaults(self): - with open(self.user_config_path, "w") as file: - file.write("anoption: value") - - with open(self.cli_config_path, "w") as file: - file.write("anoption: cli overwrite") - self.run_command("--config", self.cli_config_path, "test", lib=None) - assert config["anoption"].get() == "cli overwrite" - - def test_cli_config_file_overwrites_beetsdir_defaults(self): - os.environ["BEETSDIR"] = str(self.beetsdir) - with open(self.env_config_path, "w") as file: - file.write("anoption: value") - - with open(self.cli_config_path, "w") as file: - file.write("anoption: cli overwrite") - self.run_command("--config", self.cli_config_path, "test", lib=None) - assert config["anoption"].get() == "cli overwrite" - - # @unittest.skip('Difficult to implement with optparse') - # def test_multiple_cli_config_files(self): - # cli_config_path_1 = os.path.join(self.temp_dir, b'config.yaml') - # cli_config_path_2 = os.path.join(self.temp_dir, b'config_2.yaml') - # - # with open(cli_config_path_1, 'w') as file: - # file.write('first: value') - # - # with open(cli_config_path_2, 'w') as file: - # file.write('second: value') - # - # self.run_command('--config', cli_config_path_1, - # '--config', cli_config_path_2, 'test', lib=None) - # assert config['first'].get() == 'value' - # assert config['second'].get() == 'value' - # - # @unittest.skip('Difficult to implement with optparse') - # def test_multiple_cli_config_overwrite(self): - # cli_overwrite_config_path = os.path.join(self.temp_dir, - # b'overwrite_config.yaml') - # - # with open(self.cli_config_path, 'w') as file: - # file.write('anoption: value') - # - # with open(cli_overwrite_config_path, 'w') as file: - # file.write('anoption: overwrite') - # - # self.run_command('--config', self.cli_config_path, - # '--config', cli_overwrite_config_path, 'test') - # assert config['anoption'].get() == 'cli overwrite' - - # FIXME: fails on windows - @unittest.skipIf(sys.platform == "win32", "win32") - def test_cli_config_paths_resolve_relative_to_user_dir(self): - with open(self.cli_config_path, "w") as file: - file.write("library: beets.db\n") - file.write("statefile: state") - - self.run_command("--config", self.cli_config_path, "test", lib=None) - assert config["library"].as_path() == self.user_config_dir / "beets.db" - assert config["statefile"].as_path() == self.user_config_dir / "state" - - def test_cli_config_paths_resolve_relative_to_beetsdir(self): - os.environ["BEETSDIR"] = str(self.beetsdir) - - with open(self.cli_config_path, "w") as file: - file.write("library: beets.db\n") - file.write("statefile: state") - - self.run_command("--config", self.cli_config_path, "test", lib=None) - assert config["library"].as_path() == self.beetsdir / "beets.db" - assert config["statefile"].as_path() == self.beetsdir / "state" - - def test_command_line_option_relative_to_working_dir(self): - config.read() - os.chdir(syspath(self.temp_dir)) - self.run_command("--library", "foo.db", "test", lib=None) - assert config["library"].as_path() == Path.cwd() / "foo.db" - - def test_cli_config_file_loads_plugin_commands(self): - with open(self.cli_config_path, "w") as file: - file.write(f"pluginpath: {_common.PLUGINPATH}\n") - file.write("plugins: test") - - self.run_command("--config", self.cli_config_path, "plugin", lib=None) - plugs = plugins.find_plugins() - assert len(plugs) == 1 - assert plugs[0].is_test_plugin - self.unload_plugins() - - def test_beetsdir_config(self): - os.environ["BEETSDIR"] = str(self.beetsdir) - - with open(self.env_config_path, "w") as file: - file.write("anoption: overwrite") - - config.read() - assert config["anoption"].get() == "overwrite" - - def test_beetsdir_points_to_file_error(self): - beetsdir = str(self.temp_dir_path / "beetsfile") - open(beetsdir, "a").close() - os.environ["BEETSDIR"] = beetsdir - with pytest.raises(ConfigError): - self.run_command("test") - - def test_beetsdir_config_does_not_load_default_user_config(self): - os.environ["BEETSDIR"] = str(self.beetsdir) - - with open(self.user_config_path, "w") as file: - file.write("anoption: value") - - config.read() - assert not config["anoption"].exists() - - def test_default_config_paths_resolve_relative_to_beetsdir(self): - os.environ["BEETSDIR"] = str(self.beetsdir) - - config.read() - assert config["library"].as_path() == self.beetsdir / "library.db" - assert config["statefile"].as_path() == self.beetsdir / "state.pickle" - - def test_beetsdir_config_paths_resolve_relative_to_beetsdir(self): - os.environ["BEETSDIR"] = str(self.beetsdir) - - with open(self.env_config_path, "w") as file: - file.write("library: beets.db\n") - file.write("statefile: state") - - config.read() - assert config["library"].as_path() == self.beetsdir / "beets.db" - assert config["statefile"].as_path() == self.beetsdir / "state" - - -class ShowModelChangeTest(IOMixin, unittest.TestCase): - def setUp(self): - super().setUp() - self.a = _common.item() - self.b = _common.item() - self.a.path = self.b.path - - def _show(self, **kwargs): - change = ui.show_model_changes(self.a, self.b, **kwargs) - out = self.io.getoutput() - return change, out - - def test_identical(self): - change, out = self._show() - assert not change - assert out == "" - - def test_string_fixed_field_change(self): - self.b.title = "x" - change, out = self._show() - assert change - assert "title" in out - - def test_int_fixed_field_change(self): - self.b.track = 9 - change, out = self._show() - assert change - assert "track" in out - - def test_floats_close_to_identical(self): - self.a.length = 1.00001 - self.b.length = 1.00005 - change, out = self._show() - assert not change - assert out == "" - - def test_floats_different(self): - self.a.length = 1.00001 - self.b.length = 2.00001 - change, out = self._show() - assert change - assert "length" in out - - def test_both_values_shown(self): - self.a.title = "foo" - self.b.title = "bar" - change, out = self._show() - assert "foo" in out - assert "bar" in out - - -class ShowChangeTest(IOMixin, unittest.TestCase): - def setUp(self): - super().setUp() - - self.items = [_common.item()] - self.items[0].track = 1 - self.items[0].path = b"/path/to/file.mp3" - self.info = autotag.AlbumInfo( - album="the album", - album_id="album id", - artist="the artist", - artist_id="artist id", - tracks=[ - autotag.TrackInfo( - title="the title", track_id="track id", index=1 - ) - ], - ) - - def _show_change( - self, - items=None, - info=None, - color=False, - cur_artist="the artist", - cur_album="the album", - dist=0.1, - ): - """Return an unicode string representing the changes""" - items = items or self.items - info = info or self.info - mapping = dict(zip(items, info.tracks)) - config["ui"]["color"] = color - config["import"]["detail"] = True - change_dist = distance(items, info, mapping) - change_dist._penalties = {"album": [dist], "artist": [dist]} - commands.show_change( - cur_artist, - cur_album, - autotag.AlbumMatch(change_dist, info, mapping, set(), set()), - ) - return self.io.getoutput().lower() - - def test_null_change(self): - msg = self._show_change() - assert "match (90.0%)" in msg - assert "album, artist" in msg - - def test_album_data_change(self): - msg = self._show_change( - cur_artist="another artist", cur_album="another album" - ) - assert "another artist -> the artist" in msg - assert "another album -> the album" in msg - - def test_item_data_change(self): - self.items[0].title = "different" - msg = self._show_change() - assert "different" in msg - assert "the title" in msg - - def test_item_data_change_with_unicode(self): - self.items[0].title = "caf\xe9" - msg = self._show_change() - assert "caf\xe9" in msg - assert "the title" in msg - - def test_album_data_change_with_unicode(self): - msg = self._show_change(cur_artist="caf\xe9", cur_album="another album") - assert "caf\xe9" in msg - assert "the artist" in msg - - def test_item_data_change_title_missing(self): - self.items[0].title = "" - msg = re.sub(r" +", " ", self._show_change()) - assert "file.mp3" in msg - assert "the title" in msg - - def test_item_data_change_title_missing_with_unicode_filename(self): - self.items[0].title = "" - self.items[0].path = "/path/to/caf\xe9.mp3".encode() - msg = re.sub(r" +", " ", self._show_change()) - assert "caf\xe9.mp3" in msg or "caf.mp3" in msg - - def test_colorize(self): - assert "test" == ui.uncolorize("test") - txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m") - assert "test" == txt - txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m test") - assert "test test" == txt - txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00mtest") - assert "testtest" == txt - txt = ui.uncolorize("test \x1b[31mtest\x1b[39;49;00m test") - assert "test test test" == txt - - def test_color_split(self): - exp = ("test", "") - res = ui.color_split("test", 5) - assert exp == res - exp = ("\x1b[31mtes\x1b[39;49;00m", "\x1b[31mt\x1b[39;49;00m") - res = ui.color_split("\x1b[31mtest\x1b[39;49;00m", 3) - assert exp == res - - def test_split_into_lines(self): - # Test uncolored text - txt = ui.split_into_lines("test test test", [5, 5, 5]) - assert txt == ["test", "test", "test"] - # Test multiple colored texts - colored_text = "\x1b[31mtest \x1b[39;49;00m" * 3 - split_txt = [ - "\x1b[31mtest\x1b[39;49;00m", - "\x1b[31mtest\x1b[39;49;00m", - "\x1b[31mtest\x1b[39;49;00m", - ] - txt = ui.split_into_lines(colored_text, [5, 5, 5]) - assert txt == split_txt - # Test single color, multi space text - colored_text = "\x1b[31m test test test \x1b[39;49;00m" - txt = ui.split_into_lines(colored_text, [5, 5, 5]) - assert txt == split_txt - # Test single color, different spacing - colored_text = "\x1b[31mtest\x1b[39;49;00mtest test test" - # ToDo: fix color_len to handle mid-text color escapes, and thus - # split colored texts over newlines (potentially with dashes?) - split_txt = ["\x1b[31mtest\x1b[39;49;00mt", "est", "test", "test"] - txt = ui.split_into_lines(colored_text, [5, 5, 5]) - assert txt == split_txt - - def test_album_data_change_wrap_newline(self): - # Patch ui.term_width to force wrapping - with patch("beets.ui.commands.ui.term_width", return_value=30): - # Test newline layout - config["ui"]["import"]["layout"] = "newline" - long_name = f"another artist with a{' very' * 10} long name" - msg = self._show_change( - cur_artist=long_name, cur_album="another album" - ) - assert "artist: another artist" in msg - assert " -> the artist" in msg - assert "another album -> the album" not in msg - - def test_item_data_change_wrap_column(self): - # Patch ui.term_width to force wrapping - with patch("beets.ui.commands.ui.term_width", return_value=54): - # Test Column layout - config["ui"]["import"]["layout"] = "column" - long_title = f"a track with a{' very' * 10} long name" - self.items[0].title = long_title - msg = self._show_change() - assert "(#1) a track (1:00) -> (#1) the title (0:00)" in msg - - def test_item_data_change_wrap_newline(self): - # Patch ui.term_width to force wrapping - with patch("beets.ui.commands.ui.term_width", return_value=30): - config["ui"]["import"]["layout"] = "newline" - long_title = f"a track with a{' very' * 10} long name" - self.items[0].title = long_title - msg = self._show_change() - assert "(#1) a track with" in msg - assert " -> (#1) the title (0:00)" in msg - - -@patch("beets.library.Item.try_filesize", Mock(return_value=987)) -class SummarizeItemsTest(unittest.TestCase): - def setUp(self): - super().setUp() - item = library.Item() - item.bitrate = 4321 - item.length = 10 * 60 + 54 - item.format = "F" - self.item = item - - def test_summarize_item(self): - summary = commands.summarize_items([], True) - assert summary == "" - - summary = commands.summarize_items([self.item], True) - assert summary == "F, 4kbps, 10:54, 987.0 B" - - def test_summarize_items(self): - summary = commands.summarize_items([], False) - assert summary == "0 items" - - summary = commands.summarize_items([self.item], False) - assert summary == "1 items, F, 4kbps, 10:54, 987.0 B" - - # make a copy of self.item - i2 = self.item.copy() - - summary = commands.summarize_items([self.item, i2], False) - assert summary == "2 items, F, 4kbps, 21:48, 1.9 KiB" - - i2.format = "G" - summary = commands.summarize_items([self.item, i2], False) - assert summary == "2 items, F 1, G 1, 4kbps, 21:48, 1.9 KiB" - - summary = commands.summarize_items([self.item, i2, i2], False) - assert summary == "3 items, G 2, F 1, 4kbps, 32:42, 2.9 KiB" - - -class PathFormatTest(unittest.TestCase): - def test_custom_paths_prepend(self): - default_formats = ui.get_path_formats() - - config["paths"] = {"foo": "bar"} - pf = ui.get_path_formats() - key, tmpl = pf[0] - assert key == "foo" - assert tmpl.original == "bar" - assert pf[1:] == default_formats - - -@_common.slow_test() -class PluginTest(TestPluginTestCase): - def test_plugin_command_from_pluginpath(self): - self.run_command("test", lib=None) - - -@_common.slow_test() -@pytest.mark.xfail( - os.environ.get("GITHUB_ACTIONS") == "true" and sys.platform == "linux", - reason="Completion is for some reason unhappy on Ubuntu 24.04 in CI", -) -class CompletionTest(IOMixin, TestPluginTestCase): - def test_completion(self): - # Do not load any other bash completion scripts on the system. - env = dict(os.environ) - env["BASH_COMPLETION_DIR"] = os.devnull - env["BASH_COMPLETION_COMPAT_DIR"] = os.devnull - - # Open a `bash` process to run the tests in. We'll pipe in bash - # commands via stdin. - cmd = os.environ.get("BEETS_TEST_SHELL", "/bin/bash --norc").split() - if not has_program(cmd[0]): - self.skipTest("bash not available") - tester = subprocess.Popen( - cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=env - ) - - # Load bash_completion library. - for path in commands.BASH_COMPLETION_PATHS: - if os.path.exists(syspath(path)): - bash_completion = path - break - else: - self.skipTest("bash-completion script not found") - try: - with open(util.syspath(bash_completion), "rb") as f: - tester.stdin.writelines(f) - except OSError: - self.skipTest("could not read bash-completion script") - - # Load completion script. - self.run_command("completion", lib=None) - completion_script = self.io.getoutput().encode("utf-8") - self.io.restore() - tester.stdin.writelines(completion_script.splitlines(True)) - - # Load test suite. - test_script_name = os.path.join(_common.RSRC, b"test_completion.sh") - with open(test_script_name, "rb") as test_script_file: - tester.stdin.writelines(test_script_file) - out, err = tester.communicate() - assert tester.returncode == 0 - assert out == b"completion tests passed\n", ( - "test/test_completion.sh did not execute properly. " - f"Output:{out.decode('utf-8')}" - ) - - -class CommonOptionsParserCliTest(BeetsTestCase): - """Test CommonOptionsParser and formatting LibModel formatting on 'list' - command. - """ - - def setUp(self): - super().setUp() - self.item = _common.item() - self.item.path = b"xxx/yyy" - self.lib.add(self.item) - self.lib.add_album([self.item]) - - def test_base(self): - output = self.run_with_output("ls") - assert output == "the artist - the album - the title\n" - - output = self.run_with_output("ls", "-a") - assert output == "the album artist - the album\n" - - def test_path_option(self): - output = self.run_with_output("ls", "-p") - assert output == "xxx/yyy\n" - - output = self.run_with_output("ls", "-a", "-p") - assert output == "xxx\n" - - def test_format_option(self): - output = self.run_with_output("ls", "-f", "$artist") - assert output == "the artist\n" - - output = self.run_with_output("ls", "-a", "-f", "$albumartist") - assert output == "the album artist\n" - - def test_format_option_unicode(self): - output = self.run_with_output("ls", "-f", "caf\xe9") - assert output == "caf\xe9\n" - - def test_root_format_option(self): - output = self.run_with_output( - "--format-item", "$artist", "--format-album", "foo", "ls" - ) - assert output == "the artist\n" - - output = self.run_with_output( - "--format-item", "foo", "--format-album", "$albumartist", "ls", "-a" - ) - assert output == "the album artist\n" - - def test_help(self): - output = self.run_with_output("help") - assert "Usage:" in output - - output = self.run_with_output("help", "list") - assert "Usage:" in output - - with pytest.raises(ui.UserError): - self.run_command("help", "this.is.not.a.real.command") - - def test_stats(self): - output = self.run_with_output("stats") - assert "Approximate total size:" in output - - # # Need to have more realistic library setup for this to work - # output = self.run_with_output('stats', '-e') - # assert 'Total size:' in output - - def test_version(self): - output = self.run_with_output("version") - assert "Python version" in output - assert "no plugins loaded" in output - - # # Need to have plugin loaded - # output = self.run_with_output('version') - # assert 'plugins: ' in output - - -class CommonOptionsParserTest(unittest.TestCase): - def test_album_option(self): - parser = ui.CommonOptionsParser() - assert not parser._album_flags - parser.add_album_option() - assert bool(parser._album_flags) - - assert parser.parse_args([]) == ({"album": None}, []) - assert parser.parse_args(["-a"]) == ({"album": True}, []) - assert parser.parse_args(["--album"]) == ({"album": True}, []) - - def test_path_option(self): - parser = ui.CommonOptionsParser() - parser.add_path_option() - assert not parser._album_flags - - config["format_item"].set("$foo") - assert parser.parse_args([]) == ({"path": None}, []) - assert config["format_item"].as_str() == "$foo" - - assert parser.parse_args(["-p"]) == ( - {"path": True, "format": "$path"}, - [], - ) - assert parser.parse_args(["--path"]) == ( - {"path": True, "format": "$path"}, - [], - ) - - assert config["format_item"].as_str() == "$path" - assert config["format_album"].as_str() == "$path" - - def test_format_option(self): - parser = ui.CommonOptionsParser() - parser.add_format_option() - assert not parser._album_flags - - config["format_item"].set("$foo") - assert parser.parse_args([]) == ({"format": None}, []) - assert config["format_item"].as_str() == "$foo" - - assert parser.parse_args(["-f", "$bar"]) == ({"format": "$bar"}, []) - assert parser.parse_args(["--format", "$baz"]) == ( - {"format": "$baz"}, - [], - ) - - assert config["format_item"].as_str() == "$baz" - assert config["format_album"].as_str() == "$baz" - - def test_format_option_with_target(self): - with pytest.raises(KeyError): - ui.CommonOptionsParser().add_format_option(target="thingy") - - parser = ui.CommonOptionsParser() - parser.add_format_option(target="item") - - config["format_item"].set("$item") - config["format_album"].set("$album") - - assert parser.parse_args(["-f", "$bar"]) == ({"format": "$bar"}, []) - - assert config["format_item"].as_str() == "$bar" - assert config["format_album"].as_str() == "$album" - - def test_format_option_with_album(self): - parser = ui.CommonOptionsParser() - parser.add_album_option() - parser.add_format_option() - - config["format_item"].set("$item") - config["format_album"].set("$album") - - parser.parse_args(["-f", "$bar"]) - assert config["format_item"].as_str() == "$bar" - assert config["format_album"].as_str() == "$album" - - parser.parse_args(["-a", "-f", "$foo"]) - assert config["format_item"].as_str() == "$bar" - assert config["format_album"].as_str() == "$foo" - - parser.parse_args(["-f", "$foo2", "-a"]) - assert config["format_album"].as_str() == "$foo2" - - def test_add_all_common_options(self): - parser = ui.CommonOptionsParser() - parser.add_all_common_options() - assert parser.parse_args([]) == ( - {"album": None, "path": None, "format": None}, - [], - ) - - -class EncodingTest(unittest.TestCase): - """Tests for the `terminal_encoding` config option and our - `_in_encoding` and `_out_encoding` utility functions. - """ - - def out_encoding_overridden(self): - config["terminal_encoding"] = "fake_encoding" - assert ui._out_encoding() == "fake_encoding" - - def in_encoding_overridden(self): - config["terminal_encoding"] = "fake_encoding" - assert ui._in_encoding() == "fake_encoding" - - def out_encoding_default_utf8(self): - with patch("sys.stdout") as stdout: - stdout.encoding = None - assert ui._out_encoding() == "utf-8" - - def in_encoding_default_utf8(self): - with patch("sys.stdin") as stdin: - stdin.encoding = None - assert ui._in_encoding() == "utf-8" diff --git a/test/ui/__init__.py b/test/ui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/ui/commands/__init__.py b/test/ui/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/ui/commands/test_completion.py b/test/ui/commands/test_completion.py new file mode 100644 index 0000000000..f1e53f2380 --- /dev/null +++ b/test/ui/commands/test_completion.py @@ -0,0 +1,64 @@ +import os +import subprocess +import sys + +import pytest + +from beets.test import _common +from beets.test.helper import IOMixin, has_program +from beets.ui.commands.completion import BASH_COMPLETION_PATHS +from beets.util import syspath + +from ..test_ui import TestPluginTestCase + + +@_common.slow_test() +@pytest.mark.xfail( + os.environ.get("GITHUB_ACTIONS") == "true" and sys.platform == "linux", + reason="Completion is for some reason unhappy on Ubuntu 24.04 in CI", +) +class CompletionTest(IOMixin, TestPluginTestCase): + def test_completion(self): + # Do not load any other bash completion scripts on the system. + env = dict(os.environ) + env["BASH_COMPLETION_DIR"] = os.devnull + env["BASH_COMPLETION_COMPAT_DIR"] = os.devnull + + # Open a `bash` process to run the tests in. We'll pipe in bash + # commands via stdin. + cmd = os.environ.get("BEETS_TEST_SHELL", "/bin/bash --norc").split() + if not has_program(cmd[0]): + self.skipTest("bash not available") + tester = subprocess.Popen( + cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=env + ) + + # Load bash_completion library. + for path in BASH_COMPLETION_PATHS: + if os.path.exists(syspath(path)): + bash_completion = path + break + else: + self.skipTest("bash-completion script not found") + try: + with open(syspath(bash_completion), "rb") as f: + tester.stdin.writelines(f) + except OSError: + self.skipTest("could not read bash-completion script") + + # Load completion script. + self.run_command("completion", lib=None) + completion_script = self.io.getoutput().encode("utf-8") + self.io.restore() + tester.stdin.writelines(completion_script.splitlines(True)) + + # Load test suite. + test_script_name = os.path.join(_common.RSRC, b"test_completion.sh") + with open(test_script_name, "rb") as test_script_file: + tester.stdin.writelines(test_script_file) + out, err = tester.communicate() + assert tester.returncode == 0 + assert out == b"completion tests passed\n", ( + "test/test_completion.sh did not execute properly. " + f"Output:{out.decode('utf-8')}" + ) diff --git a/test/test_config_command.py b/test/ui/commands/test_config.py similarity index 100% rename from test/test_config_command.py rename to test/ui/commands/test_config.py diff --git a/test/ui/commands/test_fields.py b/test/ui/commands/test_fields.py new file mode 100644 index 0000000000..0eaaa9ceb5 --- /dev/null +++ b/test/ui/commands/test_fields.py @@ -0,0 +1,24 @@ +from beets import library +from beets.test.helper import IOMixin, ItemInDBTestCase +from beets.ui.commands.fields import fields_func + + +class FieldsTest(IOMixin, ItemInDBTestCase): + def remove_keys(self, keys, text): + for i in text: + try: + keys.remove(i) + except ValueError: + pass + + def test_fields_func(self): + fields_func(self.lib, [], []) + items = library.Item.all_keys() + albums = library.Album.all_keys() + + output = self.io.stdout.get().split() + self.remove_keys(items, output) + self.remove_keys(albums, output) + + assert len(items) == 0 + assert len(albums) == 0 diff --git a/test/ui/commands/test_import.py b/test/ui/commands/test_import.py new file mode 100644 index 0000000000..54f2612ebb --- /dev/null +++ b/test/ui/commands/test_import.py @@ -0,0 +1,263 @@ +import os +import re +import unittest +from unittest.mock import Mock, patch + +import pytest + +from beets import autotag, config, library, ui +from beets.autotag.match import distance +from beets.test import _common +from beets.test.helper import BeetsTestCase, IOMixin +from beets.ui.commands._utils import paths_from_logfile +from beets.ui.commands.import_ import import_files +from beets.ui.commands.import_.display import show_change +from beets.ui.commands.import_.session import summarize_items + + +class ImportTest(BeetsTestCase): + def test_quiet_timid_disallowed(self): + config["import"]["quiet"] = True + config["import"]["timid"] = True + with pytest.raises(ui.UserError): + import_files(None, [], None) + + def test_parse_paths_from_logfile(self): + if os.path.__name__ == "ntpath": + logfile_content = ( + "import started Wed Jun 15 23:08:26 2022\n" + "asis C:\\music\\Beatles, The\\The Beatles; C:\\music\\Beatles, The\\The Beatles\\CD 01; C:\\music\\Beatles, The\\The Beatles\\CD 02\n" # noqa: E501 + "duplicate-replace C:\\music\\Bill Evans\\Trio '65\n" + "skip C:\\music\\Michael Jackson\\Bad\n" + "skip C:\\music\\Soulwax\\Any Minute Now\n" + ) + expected_paths = [ + "C:\\music\\Beatles, The\\The Beatles", + "C:\\music\\Michael Jackson\\Bad", + "C:\\music\\Soulwax\\Any Minute Now", + ] + else: + logfile_content = ( + "import started Wed Jun 15 23:08:26 2022\n" + "asis /music/Beatles, The/The Beatles; /music/Beatles, The/The Beatles/CD 01; /music/Beatles, The/The Beatles/CD 02\n" # noqa: E501 + "duplicate-replace /music/Bill Evans/Trio '65\n" + "skip /music/Michael Jackson/Bad\n" + "skip /music/Soulwax/Any Minute Now\n" + ) + expected_paths = [ + "/music/Beatles, The/The Beatles", + "/music/Michael Jackson/Bad", + "/music/Soulwax/Any Minute Now", + ] + + logfile = os.path.join(self.temp_dir, b"logfile.log") + with open(logfile, mode="w") as fp: + fp.write(logfile_content) + actual_paths = list(paths_from_logfile(logfile)) + assert actual_paths == expected_paths + + +class ShowChangeTest(IOMixin, unittest.TestCase): + def setUp(self): + super().setUp() + + self.items = [_common.item()] + self.items[0].track = 1 + self.items[0].path = b"/path/to/file.mp3" + self.info = autotag.AlbumInfo( + album="the album", + album_id="album id", + artist="the artist", + artist_id="artist id", + tracks=[ + autotag.TrackInfo( + title="the title", track_id="track id", index=1 + ) + ], + ) + + def _show_change( + self, + items=None, + info=None, + color=False, + cur_artist="the artist", + cur_album="the album", + dist=0.1, + ): + """Return an unicode string representing the changes""" + items = items or self.items + info = info or self.info + mapping = dict(zip(items, info.tracks)) + config["ui"]["color"] = color + config["import"]["detail"] = True + change_dist = distance(items, info, mapping) + change_dist._penalties = {"album": [dist], "artist": [dist]} + show_change( + cur_artist, + cur_album, + autotag.AlbumMatch(change_dist, info, mapping, set(), set()), + ) + return self.io.getoutput().lower() + + def test_null_change(self): + msg = self._show_change() + assert "match (90.0%)" in msg + assert "album, artist" in msg + + def test_album_data_change(self): + msg = self._show_change( + cur_artist="another artist", cur_album="another album" + ) + assert "another artist -> the artist" in msg + assert "another album -> the album" in msg + + def test_item_data_change(self): + self.items[0].title = "different" + msg = self._show_change() + assert "different" in msg + assert "the title" in msg + + def test_item_data_change_with_unicode(self): + self.items[0].title = "caf\xe9" + msg = self._show_change() + assert "caf\xe9" in msg + assert "the title" in msg + + def test_album_data_change_with_unicode(self): + msg = self._show_change(cur_artist="caf\xe9", cur_album="another album") + assert "caf\xe9" in msg + assert "the artist" in msg + + def test_item_data_change_title_missing(self): + self.items[0].title = "" + msg = re.sub(r" +", " ", self._show_change()) + assert "file.mp3" in msg + assert "the title" in msg + + def test_item_data_change_title_missing_with_unicode_filename(self): + self.items[0].title = "" + self.items[0].path = "/path/to/caf\xe9.mp3".encode() + msg = re.sub(r" +", " ", self._show_change()) + assert "caf\xe9.mp3" in msg or "caf.mp3" in msg + + def test_colorize(self): + assert "test" == ui.uncolorize("test") + txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m") + assert "test" == txt + txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m test") + assert "test test" == txt + txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00mtest") + assert "testtest" == txt + txt = ui.uncolorize("test \x1b[31mtest\x1b[39;49;00m test") + assert "test test test" == txt + + def test_color_split(self): + exp = ("test", "") + res = ui.color_split("test", 5) + assert exp == res + exp = ("\x1b[31mtes\x1b[39;49;00m", "\x1b[31mt\x1b[39;49;00m") + res = ui.color_split("\x1b[31mtest\x1b[39;49;00m", 3) + assert exp == res + + def test_split_into_lines(self): + # Test uncolored text + txt = ui.split_into_lines("test test test", [5, 5, 5]) + assert txt == ["test", "test", "test"] + # Test multiple colored texts + colored_text = "\x1b[31mtest \x1b[39;49;00m" * 3 + split_txt = [ + "\x1b[31mtest\x1b[39;49;00m", + "\x1b[31mtest\x1b[39;49;00m", + "\x1b[31mtest\x1b[39;49;00m", + ] + txt = ui.split_into_lines(colored_text, [5, 5, 5]) + assert txt == split_txt + # Test single color, multi space text + colored_text = "\x1b[31m test test test \x1b[39;49;00m" + txt = ui.split_into_lines(colored_text, [5, 5, 5]) + assert txt == split_txt + # Test single color, different spacing + colored_text = "\x1b[31mtest\x1b[39;49;00mtest test test" + # ToDo: fix color_len to handle mid-text color escapes, and thus + # split colored texts over newlines (potentially with dashes?) + split_txt = ["\x1b[31mtest\x1b[39;49;00mt", "est", "test", "test"] + txt = ui.split_into_lines(colored_text, [5, 5, 5]) + assert txt == split_txt + + def test_album_data_change_wrap_newline(self): + # Patch ui.term_width to force wrapping + with patch( + "beets.ui.commands.import_.display.term_width", return_value=30 + ): + # Test newline layout + config["ui"]["import"]["layout"] = "newline" + long_name = f"another artist with a{' very' * 10} long name" + msg = self._show_change( + cur_artist=long_name, cur_album="another album" + ) + assert "artist: another artist" in msg + assert " -> the artist" in msg + assert "another album -> the album" not in msg + + def test_item_data_change_wrap_column(self): + # Patch ui.term_width to force wrapping + with patch( + "beets.ui.commands.import_.display.term_width", return_value=54 + ): + # Test Column layout + config["ui"]["import"]["layout"] = "column" + long_title = f"a track with a{' very' * 10} long name" + self.items[0].title = long_title + msg = self._show_change() + assert "(#1) a track (1:00) -> (#1) the title (0:00)" in msg + + def test_item_data_change_wrap_newline(self): + # Patch ui.term_width to force wrapping + with patch( + "beets.ui.commands.import_.display.term_width", return_value=30 + ): + config["ui"]["import"]["layout"] = "newline" + long_title = f"a track with a{' very' * 10} long name" + self.items[0].title = long_title + msg = self._show_change() + assert "(#1) a track with" in msg + assert " -> (#1) the title (0:00)" in msg + + +@patch("beets.library.Item.try_filesize", Mock(return_value=987)) +class SummarizeItemsTest(unittest.TestCase): + def setUp(self): + super().setUp() + item = library.Item() + item.bitrate = 4321 + item.length = 10 * 60 + 54 + item.format = "F" + self.item = item + + def test_summarize_item(self): + summary = summarize_items([], True) + assert summary == "" + + summary = summarize_items([self.item], True) + assert summary == "F, 4kbps, 10:54, 987.0 B" + + def test_summarize_items(self): + summary = summarize_items([], False) + assert summary == "0 items" + + summary = summarize_items([self.item], False) + assert summary == "1 items, F, 4kbps, 10:54, 987.0 B" + + # make a copy of self.item + i2 = self.item.copy() + + summary = summarize_items([self.item, i2], False) + assert summary == "2 items, F, 4kbps, 21:48, 1.9 KiB" + + i2.format = "G" + summary = summarize_items([self.item, i2], False) + assert summary == "2 items, F 1, G 1, 4kbps, 21:48, 1.9 KiB" + + summary = summarize_items([self.item, i2, i2], False) + assert summary == "3 items, G 2, F 1, 4kbps, 32:42, 2.9 KiB" diff --git a/test/ui/commands/test_list.py b/test/ui/commands/test_list.py new file mode 100644 index 0000000000..a63a56ad1a --- /dev/null +++ b/test/ui/commands/test_list.py @@ -0,0 +1,69 @@ +from beets.test import _common +from beets.test.helper import BeetsTestCase, capture_stdout +from beets.ui.commands.list import list_items + + +class ListTest(BeetsTestCase): + def setUp(self): + super().setUp() + self.item = _common.item() + self.item.path = "xxx/yyy" + self.lib.add(self.item) + self.lib.add_album([self.item]) + + def _run_list(self, query="", album=False, path=False, fmt=""): + with capture_stdout() as stdout: + list_items(self.lib, query, album, fmt) + return stdout + + def test_list_outputs_item(self): + stdout = self._run_list() + assert "the title" in stdout.getvalue() + + def test_list_unicode_query(self): + self.item.title = "na\xefve" + self.item.store() + self.lib._connection().commit() + + stdout = self._run_list(["na\xefve"]) + out = stdout.getvalue() + assert "na\xefve" in out + + def test_list_item_path(self): + stdout = self._run_list(fmt="$path") + assert stdout.getvalue().strip() == "xxx/yyy" + + def test_list_album_outputs_something(self): + stdout = self._run_list(album=True) + assert len(stdout.getvalue()) > 0 + + def test_list_album_path(self): + stdout = self._run_list(album=True, fmt="$path") + assert stdout.getvalue().strip() == "xxx" + + def test_list_album_omits_title(self): + stdout = self._run_list(album=True) + assert "the title" not in stdout.getvalue() + + def test_list_uses_track_artist(self): + stdout = self._run_list() + assert "the artist" in stdout.getvalue() + assert "the album artist" not in stdout.getvalue() + + def test_list_album_uses_album_artist(self): + stdout = self._run_list(album=True) + assert "the artist" not in stdout.getvalue() + assert "the album artist" in stdout.getvalue() + + def test_list_item_format_artist(self): + stdout = self._run_list(fmt="$artist") + assert "the artist" in stdout.getvalue() + + def test_list_item_format_multiple(self): + stdout = self._run_list(fmt="$artist - $album - $year") + assert "the artist - the album - 0001" == stdout.getvalue().strip() + + def test_list_album_format(self): + stdout = self._run_list(album=True, fmt="$genre") + assert "the genre" in stdout.getvalue() + assert "the album" not in stdout.getvalue() diff --git a/test/ui/commands/test_modify.py b/test/ui/commands/test_modify.py new file mode 100644 index 0000000000..b9cc1524d7 --- /dev/null +++ b/test/ui/commands/test_modify.py @@ -0,0 +1,216 @@ +import unittest + +from mediafile import MediaFile + +from beets.test.helper import BeetsTestCase, control_stdin +from beets.ui.commands.modify import modify_parse_args +from beets.util import syspath + + +class ModifyTest(BeetsTestCase): + def setUp(self): + super().setUp() + self.album = self.add_album_fixture() + [self.item] = self.album.items() + + def modify_inp(self, inp, *args): + with control_stdin(inp): + self.run_command("modify", *args) + + def modify(self, *args): + self.modify_inp("y", *args) + + # Item tests + + def test_modify_item(self): + self.modify("title=newTitle") + item = self.lib.items().get() + assert item.title == "newTitle" + + def test_modify_item_abort(self): + item = self.lib.items().get() + title = item.title + self.modify_inp("n", "title=newTitle") + item = self.lib.items().get() + assert item.title == title + + def test_modify_item_no_change(self): + title = "Tracktitle" + item = self.add_item_fixture(title=title) + self.modify_inp("y", "title", f"title={title}") + item = self.lib.items(title).get() + assert item.title == title + + def test_modify_write_tags(self): + self.modify("title=newTitle") + item = self.lib.items().get() + item.read() + assert item.title == "newTitle" + + def test_modify_dont_write_tags(self): + self.modify("--nowrite", "title=newTitle") + item = self.lib.items().get() + item.read() + assert item.title != "newTitle" + + def test_move(self): + self.modify("title=newTitle") + item = self.lib.items().get() + assert b"newTitle" in item.path + + def test_not_move(self): + self.modify("--nomove", "title=newTitle") + item = self.lib.items().get() + assert b"newTitle" not in item.path + + def test_no_write_no_move(self): + self.modify("--nomove", "--nowrite", "title=newTitle") + item = self.lib.items().get() + item.read() + assert b"newTitle" not in item.path + assert item.title != "newTitle" + + def test_update_mtime(self): + item = self.item + old_mtime = item.mtime + + self.modify("title=newTitle") + item.load() + assert old_mtime != item.mtime + assert item.current_mtime() == item.mtime + + def test_reset_mtime_with_no_write(self): + item = self.item + + self.modify("--nowrite", "title=newTitle") + item.load() + assert 0 == item.mtime + + def test_selective_modify(self): + title = "Tracktitle" + album = "album" + original_artist = "composer" + new_artist = "coverArtist" + for i in range(0, 10): + self.add_item_fixture( + title=f"{title}{i}", artist=original_artist, album=album + ) + self.modify_inp( + "s\ny\ny\ny\nn\nn\ny\ny\ny\ny\nn", title, f"artist={new_artist}" + ) + original_items = self.lib.items(f"artist:{original_artist}") + new_items = self.lib.items(f"artist:{new_artist}") + assert len(list(original_items)) == 3 + assert len(list(new_items)) == 7 + + def test_modify_formatted(self): + for i in range(0, 3): + self.add_item_fixture( + title=f"title{i}", artist="artist", album="album" + ) + items = list(self.lib.items()) + self.modify("title=${title} - append") + for item in items: + orig_title = item.title + item.load() + assert item.title == f"{orig_title} - append" + + # Album Tests + + def test_modify_album(self): + self.modify("--album", "album=newAlbum") + album = self.lib.albums().get() + assert album.album == "newAlbum" + + def test_modify_album_write_tags(self): + self.modify("--album", "album=newAlbum") + item = self.lib.items().get() + item.read() + assert item.album == "newAlbum" + + def test_modify_album_dont_write_tags(self): + self.modify("--album", "--nowrite", "album=newAlbum") + item = self.lib.items().get() + item.read() + assert item.album == "the album" + + def test_album_move(self): + self.modify("--album", "album=newAlbum") + item = self.lib.items().get() + item.read() + assert b"newAlbum" in item.path + + def test_album_not_move(self): + self.modify("--nomove", "--album", "album=newAlbum") + item = self.lib.items().get() + item.read() + assert b"newAlbum" not in item.path + + def test_modify_album_formatted(self): + item = self.lib.items().get() + orig_album = item.album + self.modify("--album", "album=${album} - append") + item.load() + assert item.album == f"{orig_album} - append" + + # Misc + + def test_write_initial_key_tag(self): + self.modify("initial_key=C#m") + item = self.lib.items().get() + mediafile = MediaFile(syspath(item.path)) + assert mediafile.initial_key == "C#m" + + def test_set_flexattr(self): + self.modify("flexattr=testAttr") + item = self.lib.items().get() + assert item.flexattr == "testAttr" + + def test_remove_flexattr(self): + item = self.lib.items().get() + item.flexattr = "testAttr" + item.store() + + self.modify("flexattr!") + item = self.lib.items().get() + assert "flexattr" not in item + + @unittest.skip("not yet implemented") + def test_delete_initial_key_tag(self): + item = self.lib.items().get() + item.initial_key = "C#m" + item.write() + item.store() + + mediafile = MediaFile(syspath(item.path)) + assert mediafile.initial_key == "C#m" + + self.modify("initial_key!") + mediafile = MediaFile(syspath(item.path)) + assert mediafile.initial_key is None + + def test_arg_parsing_colon_query(self): + (query, mods, dels) = modify_parse_args( + ["title:oldTitle", "title=newTitle"] + ) + assert query == ["title:oldTitle"] + assert mods == {"title": "newTitle"} + + def test_arg_parsing_delete(self): + (query, mods, dels) = modify_parse_args(["title:oldTitle", "title!"]) + assert query == ["title:oldTitle"] + assert dels == ["title"] + + def test_arg_parsing_query_with_exclaimation(self): + (query, mods, dels) = modify_parse_args( + ["title:oldTitle!", "title=newTitle!"] + ) + assert query == ["title:oldTitle!"] + assert mods == {"title": "newTitle!"} + + def test_arg_parsing_equals_in_value(self): + (query, mods, dels) = modify_parse_args( + ["title:foo=bar", "title=newTitle"] + ) + assert query == ["title:foo=bar"] + assert mods == {"title": "newTitle"} diff --git a/test/ui/commands/test_move.py b/test/ui/commands/test_move.py new file mode 100644 index 0000000000..5c65f14754 --- /dev/null +++ b/test/ui/commands/test_move.py @@ -0,0 +1,102 @@ +import shutil + +from beets import library +from beets.test.helper import BeetsTestCase +from beets.ui.commands.move import move_items + + +class MoveTest(BeetsTestCase): + def setUp(self): + super().setUp() + + self.initial_item_path = self.lib_path / "srcfile" + shutil.copy(self.resource_path, self.initial_item_path) + + # Add a file to the library but don't copy it in yet. + self.i = library.Item.from_path(self.initial_item_path) + self.lib.add(self.i) + self.album = self.lib.add_album([self.i]) + + # Alternate destination directory. + self.otherdir = self.temp_dir_path / "testotherdir" + + def _move( + self, + query=(), + dest=None, + copy=False, + album=False, + pretend=False, + export=False, + ): + move_items(self.lib, dest, query, copy, album, pretend, export=export) + + def test_move_item(self): + self._move() + self.i.load() + assert b"libdir" in self.i.path + assert self.i.filepath.exists() + assert not self.initial_item_path.exists() + + def test_copy_item(self): + self._move(copy=True) + self.i.load() + assert b"libdir" in self.i.path + assert self.i.filepath.exists() + assert self.initial_item_path.exists() + + def test_move_album(self): + self._move(album=True) + self.i.load() + assert b"libdir" in self.i.path + assert self.i.filepath.exists() + assert not self.initial_item_path.exists() + + def test_copy_album(self): + self._move(copy=True, album=True) + self.i.load() + assert b"libdir" in self.i.path + assert self.i.filepath.exists() + assert self.initial_item_path.exists() + + def test_move_item_custom_dir(self): + self._move(dest=self.otherdir) + self.i.load() + assert b"testotherdir" in self.i.path + assert self.i.filepath.exists() + assert not self.initial_item_path.exists() + + def test_move_album_custom_dir(self): + self._move(dest=self.otherdir, album=True) + self.i.load() + assert b"testotherdir" in self.i.path + assert self.i.filepath.exists() + assert not self.initial_item_path.exists() + + def test_pretend_move_item(self): + self._move(dest=self.otherdir, pretend=True) + self.i.load() + assert self.i.filepath == self.initial_item_path + + def test_pretend_move_album(self): + self._move(album=True, pretend=True) + self.i.load() + assert self.i.filepath == self.initial_item_path + + def test_export_item_custom_dir(self): + self._move(dest=self.otherdir, export=True) + self.i.load() + assert self.i.filepath == self.initial_item_path + assert self.otherdir.exists() + + def test_export_album_custom_dir(self): + self._move(dest=self.otherdir, album=True, export=True) + self.i.load() + assert self.i.filepath == self.initial_item_path + assert self.otherdir.exists() + + def test_pretend_export_item(self): + self._move(dest=self.otherdir, pretend=True, export=True) + self.i.load() + assert self.i.filepath == self.initial_item_path + assert not self.otherdir.exists() diff --git a/test/ui/commands/test_remove.py b/test/ui/commands/test_remove.py new file mode 100644 index 0000000000..e42bb7630e --- /dev/null +++ b/test/ui/commands/test_remove.py @@ -0,0 +1,80 @@ +import os + +from beets import library +from beets.test.helper import BeetsTestCase, IOMixin +from beets.ui.commands.remove import remove_items +from beets.util import MoveOperation, syspath + + +class RemoveTest(IOMixin, BeetsTestCase): + def setUp(self): + super().setUp() + + # Copy a file into the library. + self.i = library.Item.from_path(self.resource_path) + self.lib.add(self.i) + self.i.move(operation=MoveOperation.COPY) + + def test_remove_items_no_delete(self): + self.io.addinput("y") + remove_items(self.lib, "", False, False, False) + items = self.lib.items() + assert len(list(items)) == 0 + assert self.i.filepath.exists() + + def test_remove_items_with_delete(self): + self.io.addinput("y") + remove_items(self.lib, "", False, True, False) + items = self.lib.items() + assert len(list(items)) == 0 + assert not self.i.filepath.exists() + + def test_remove_items_with_force_no_delete(self): + remove_items(self.lib, "", False, False, True) + items = self.lib.items() + assert len(list(items)) == 0 + assert self.i.filepath.exists() + + def test_remove_items_with_force_delete(self): + remove_items(self.lib, "", False, True, True) + items = self.lib.items() + assert len(list(items)) == 0 + assert not self.i.filepath.exists() + + def test_remove_items_select_with_delete(self): + i2 = library.Item.from_path(self.resource_path) + self.lib.add(i2) + i2.move(operation=MoveOperation.COPY) + + for s in ("s", "y", "n"): + self.io.addinput(s) + remove_items(self.lib, "", False, True, False) + items = self.lib.items() + assert len(list(items)) == 1 + # There is probably no guarantee that the items are queried in any + # spcecific order, thus just ensure that exactly one was removed. + # To improve upon this, self.io would need to have the capability to + # generate input that depends on previous output. + num_existing = 0 + num_existing += 1 if os.path.exists(syspath(self.i.path)) else 0 + num_existing += 1 if os.path.exists(syspath(i2.path)) else 0 + assert num_existing == 1 + + def test_remove_albums_select_with_delete(self): + a1 = self.add_album_fixture() + a2 = self.add_album_fixture() + path1 = a1.items()[0].path + path2 = a2.items()[0].path + items = self.lib.items() + assert len(list(items)) == 3 + + for s in ("s", "y", "n"): + self.io.addinput(s) + remove_items(self.lib, "", True, True, False) + items = self.lib.items() + assert len(list(items)) == 2 # incl. the item from setUp() + # See test_remove_items_select_with_delete() + num_existing = 0 + num_existing += 1 if os.path.exists(syspath(path1)) else 0 + num_existing += 1 if os.path.exists(syspath(path2)) else 0 + assert num_existing == 1 diff --git a/test/ui/commands/test_update.py b/test/ui/commands/test_update.py new file mode 100644 index 0000000000..ceb906646d --- /dev/null +++ b/test/ui/commands/test_update.py @@ -0,0 +1,205 @@ +import os + +from mediafile import MediaFile + +from beets import library, util +from beets.test import _common +from beets.test.helper import BeetsTestCase, IOMixin +from beets.ui.commands.update import update_items +from beets.util import MoveOperation, syspath + + +class UpdateTest(IOMixin, BeetsTestCase): + def setUp(self): + super().setUp() + + # Copy a file into the library. + item_path = os.path.join(_common.RSRC, b"full.mp3") + item_path_two = os.path.join(_common.RSRC, b"full.flac") + self.i = library.Item.from_path(item_path) + self.i2 = library.Item.from_path(item_path_two) + self.lib.add(self.i) + self.lib.add(self.i2) + self.i.move(operation=MoveOperation.COPY) + self.i2.move(operation=MoveOperation.COPY) + self.album = self.lib.add_album([self.i, self.i2]) + + # Album art. + artfile = os.path.join(self.temp_dir, b"testart.jpg") + _common.touch(artfile) + self.album.set_art(artfile) + self.album.store() + util.remove(artfile) + + def _update( + self, + query=(), + album=False, + move=False, + reset_mtime=True, + fields=None, + exclude_fields=None, + ): + self.io.addinput("y") + if reset_mtime: + self.i.mtime = 0 + self.i.store() + update_items( + self.lib, + query, + album, + move, + False, + fields=fields, + exclude_fields=exclude_fields, + ) + + def test_delete_removes_item(self): + assert list(self.lib.items()) + util.remove(self.i.path) + util.remove(self.i2.path) + self._update() + assert not list(self.lib.items()) + + def test_delete_removes_album(self): + assert self.lib.albums() + util.remove(self.i.path) + util.remove(self.i2.path) + self._update() + assert not self.lib.albums() + + def test_delete_removes_album_art(self): + art_filepath = self.album.art_filepath + assert art_filepath.exists() + util.remove(self.i.path) + util.remove(self.i2.path) + self._update() + assert not art_filepath.exists() + + def test_modified_metadata_detected(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = "differentTitle" + mf.save() + self._update() + item = self.lib.items().get() + assert item.title == "differentTitle" + + def test_modified_metadata_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = "differentTitle" + mf.save() + self._update(move=True) + item = self.lib.items().get() + assert b"differentTitle" in item.path + + def test_modified_metadata_not_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = "differentTitle" + mf.save() + self._update(move=False) + item = self.lib.items().get() + assert b"differentTitle" not in item.path + + def test_selective_modified_metadata_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = "differentTitle" + mf.genre = "differentGenre" + mf.save() + self._update(move=True, fields=["title"]) + item = self.lib.items().get() + assert b"differentTitle" in item.path + assert item.genre != "differentGenre" + + def test_selective_modified_metadata_not_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = "differentTitle" + mf.genre = "differentGenre" + mf.save() + self._update(move=False, fields=["title"]) + item = self.lib.items().get() + assert b"differentTitle" not in item.path + assert item.genre != "differentGenre" + + def test_modified_album_metadata_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.album = "differentAlbum" + mf.save() + self._update(move=True) + item = self.lib.items().get() + assert b"differentAlbum" in item.path + + def test_modified_album_metadata_art_moved(self): + artpath = self.album.artpath + mf = MediaFile(syspath(self.i.path)) + mf.album = "differentAlbum" + mf.save() + self._update(move=True) + album = self.lib.albums()[0] + assert artpath != album.artpath + assert album.artpath is not None + + def test_selective_modified_album_metadata_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.album = "differentAlbum" + mf.genre = "differentGenre" + mf.save() + self._update(move=True, fields=["album"]) + item = self.lib.items().get() + assert b"differentAlbum" in item.path + assert item.genre != "differentGenre" + + def test_selective_modified_album_metadata_not_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.album = "differentAlbum" + mf.genre = "differentGenre" + mf.save() + self._update(move=True, fields=["genre"]) + item = self.lib.items().get() + assert b"differentAlbum" not in item.path + assert item.genre == "differentGenre" + + def test_mtime_match_skips_update(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = "differentTitle" + mf.save() + + # Make in-memory mtime match on-disk mtime. + self.i.mtime = os.path.getmtime(syspath(self.i.path)) + self.i.store() + + self._update(reset_mtime=False) + item = self.lib.items().get() + assert item.title == "full" + + def test_multivalued_albumtype_roundtrip(self): + # https://github.com/beetbox/beets/issues/4528 + + # albumtypes is empty for our test fixtures, so populate it first + album = self.album + correct_albumtypes = ["album", "live"] + + # Setting albumtypes does not set albumtype, currently. + # Using x[0] mirrors https://github.com/beetbox/mediafile/blob/057432ad53b3b84385e5582f69f44dc00d0a725d/mediafile.py#L1928 # noqa: E501 + correct_albumtype = correct_albumtypes[0] + + album.albumtype = correct_albumtype + album.albumtypes = correct_albumtypes + album.try_sync(write=True, move=False) + + album.load() + assert album.albumtype == correct_albumtype + assert album.albumtypes == correct_albumtypes + + self._update() + + album.load() + assert album.albumtype == correct_albumtype + assert album.albumtypes == correct_albumtypes + + def test_modified_metadata_excluded(self): + mf = MediaFile(syspath(self.i.path)) + mf.lyrics = "new lyrics" + mf.save() + self._update(exclude_fields=["lyrics"]) + item = self.lib.items().get() + assert item.lyrics != "new lyrics" diff --git a/test/test_ui_commands.py b/test/ui/commands/test_utils.py similarity index 50% rename from test/test_ui_commands.py rename to test/ui/commands/test_utils.py index 412ddc2b7a..66184562df 100644 --- a/test/test_ui_commands.py +++ b/test/ui/commands/test_utils.py @@ -1,19 +1,3 @@ -# This file is part of beets. -# Copyright 2016, Adrian Sampson. -# -# 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. - -"""Test module for file ui/commands.py""" - import os import shutil @@ -21,8 +5,8 @@ from beets import library, ui from beets.test import _common -from beets.test.helper import BeetsTestCase, IOMixin, ItemInDBTestCase -from beets.ui import commands +from beets.test.helper import BeetsTestCase +from beets.ui.commands._utils import do_query from beets.util import syspath @@ -44,17 +28,17 @@ def add_album(self, items): def check_do_query( self, num_items, num_albums, q=(), album=False, also_items=True ): - items, albums = commands._do_query(self.lib, q, album, also_items) + items, albums = do_query(self.lib, q, album, also_items) assert len(items) == num_items assert len(albums) == num_albums def test_query_empty(self): with pytest.raises(ui.UserError): - commands._do_query(self.lib, (), False) + do_query(self.lib, (), False) def test_query_empty_album(self): with pytest.raises(ui.UserError): - commands._do_query(self.lib, (), True) + do_query(self.lib, (), True) def test_query_item(self): self.add_item() @@ -73,24 +57,3 @@ def test_query_album(self): self.add_album([item, item2]) self.check_do_query(3, 2, album=True) self.check_do_query(0, 2, album=True, also_items=False) - - -class FieldsTest(IOMixin, ItemInDBTestCase): - def remove_keys(self, keys, text): - for i in text: - try: - keys.remove(i) - except ValueError: - pass - - def test_fields_func(self): - commands.fields_func(self.lib, [], []) - items = library.Item.all_keys() - albums = library.Album.all_keys() - - output = self.io.stdout.get().split() - self.remove_keys(items, output) - self.remove_keys(albums, output) - - assert len(items) == 0 - assert len(albums) == 0 diff --git a/test/ui/commands/test_write.py b/test/ui/commands/test_write.py new file mode 100644 index 0000000000..312b51dd29 --- /dev/null +++ b/test/ui/commands/test_write.py @@ -0,0 +1,46 @@ +from beets.test.helper import BeetsTestCase + + +class WriteTest(BeetsTestCase): + def write_cmd(self, *args): + return self.run_with_output("write", *args) + + def test_update_mtime(self): + item = self.add_item_fixture() + item["title"] = "a new title" + item.store() + + item = self.lib.items().get() + assert item.mtime == 0 + + self.write_cmd() + item = self.lib.items().get() + assert item.mtime == item.current_mtime() + + def test_non_metadata_field_unchanged(self): + """Changing a non-"tag" field like `bitrate` and writing should + have no effect. + """ + # An item that starts out "clean". + item = self.add_item_fixture() + item.read() + + # ... but with a mismatched bitrate. + item.bitrate = 123 + item.store() + + output = self.write_cmd() + + assert output == "" + + def test_write_metadata_field(self): + item = self.add_item_fixture() + item.read() + old_title = item.title + + item.title = "new title" + item.store() + + output = self.write_cmd() + + assert f"{old_title} -> new title" in output diff --git a/test/ui/test_ui.py b/test/ui/test_ui.py new file mode 100644 index 0000000000..a37d4bb29e --- /dev/null +++ b/test/ui/test_ui.py @@ -0,0 +1,590 @@ +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# 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 command-line interface.""" + +import os +import platform +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +import pytest +from confuse import ConfigError + +from beets import config, plugins, ui +from beets.test import _common +from beets.test.helper import BeetsTestCase, IOMixin, PluginTestCase +from beets.ui import commands +from beets.util import syspath + + +class PrintTest(IOMixin, unittest.TestCase): + def test_print_without_locale(self): + lang = os.environ.get("LANG") + if lang: + del os.environ["LANG"] + + try: + ui.print_("something") + except TypeError: + self.fail("TypeError during print") + finally: + if lang: + os.environ["LANG"] = lang + + def test_print_with_invalid_locale(self): + old_lang = os.environ.get("LANG") + os.environ["LANG"] = "" + old_ctype = os.environ.get("LC_CTYPE") + os.environ["LC_CTYPE"] = "UTF-8" + + try: + ui.print_("something") + except ValueError: + self.fail("ValueError during print") + finally: + if old_lang: + os.environ["LANG"] = old_lang + else: + del os.environ["LANG"] + if old_ctype: + os.environ["LC_CTYPE"] = old_ctype + else: + del os.environ["LC_CTYPE"] + + +@_common.slow_test() +class TestPluginTestCase(PluginTestCase): + plugin = "test" + + def setUp(self): + super().setUp() + config["pluginpath"] = [_common.PLUGINPATH] + + +class ConfigTest(TestPluginTestCase): + def setUp(self): + super().setUp() + + # Don't use the BEETSDIR from `helper`. Instead, we point the home + # directory there. Some tests will set `BEETSDIR` themselves. + del os.environ["BEETSDIR"] + + # Also set APPDATA, the Windows equivalent of setting $HOME. + appdata_dir = self.temp_dir_path / "AppData" / "Roaming" + + self._orig_cwd = os.getcwd() + self.test_cmd = self._make_test_cmd() + commands.default_commands.append(self.test_cmd) + + # Default user configuration + if platform.system() == "Windows": + self.user_config_dir = appdata_dir / "beets" + else: + self.user_config_dir = self.temp_dir_path / ".config" / "beets" + self.user_config_dir.mkdir(parents=True, exist_ok=True) + self.user_config_path = self.user_config_dir / "config.yaml" + + # Custom BEETSDIR + self.beetsdir = self.temp_dir_path / "beetsdir" + self.beetsdir.mkdir(parents=True, exist_ok=True) + + self.env_config_path = str(self.beetsdir / "config.yaml") + self.cli_config_path = str(self.temp_dir_path / "config.yaml") + self.env_patcher = patch( + "os.environ", + {"HOME": str(self.temp_dir_path), "APPDATA": str(appdata_dir)}, + ) + self.env_patcher.start() + + self._reset_config() + + def tearDown(self): + self.env_patcher.stop() + commands.default_commands.pop() + os.chdir(syspath(self._orig_cwd)) + super().tearDown() + + def _make_test_cmd(self): + test_cmd = ui.Subcommand("test", help="test") + + def run(lib, options, args): + test_cmd.lib = lib + test_cmd.options = options + test_cmd.args = args + + test_cmd.func = run + return test_cmd + + def _reset_config(self): + # Config should read files again on demand + config.clear() + config._materialized = False + + def write_config_file(self): + return open(self.user_config_path, "w") + + def test_paths_section_respected(self): + with self.write_config_file() as config: + config.write("paths: {x: y}") + + self.run_command("test", lib=None) + key, template = self.test_cmd.lib.path_formats[0] + assert key == "x" + assert template.original == "y" + + def test_default_paths_preserved(self): + default_formats = ui.get_path_formats() + + self._reset_config() + with self.write_config_file() as config: + config.write("paths: {x: y}") + self.run_command("test", lib=None) + key, template = self.test_cmd.lib.path_formats[0] + assert key == "x" + assert template.original == "y" + assert self.test_cmd.lib.path_formats[1:] == default_formats + + def test_nonexistant_db(self): + with self.write_config_file() as config: + config.write("library: /xxx/yyy/not/a/real/path") + + with pytest.raises(ui.UserError): + self.run_command("test", lib=None) + + def test_user_config_file(self): + with self.write_config_file() as file: + file.write("anoption: value") + + self.run_command("test", lib=None) + assert config["anoption"].get() == "value" + + def test_replacements_parsed(self): + with self.write_config_file() as config: + config.write("replace: {'[xy]': z}") + + self.run_command("test", lib=None) + replacements = self.test_cmd.lib.replacements + repls = [(p.pattern, s) for p, s in replacements] # Compare patterns. + assert repls == [("[xy]", "z")] + + def test_multiple_replacements_parsed(self): + with self.write_config_file() as config: + config.write("replace: {'[xy]': z, foo: bar}") + self.run_command("test", lib=None) + replacements = self.test_cmd.lib.replacements + repls = [(p.pattern, s) for p, s in replacements] + assert repls == [("[xy]", "z"), ("foo", "bar")] + + def test_cli_config_option(self): + with open(self.cli_config_path, "w") as file: + file.write("anoption: value") + self.run_command("--config", self.cli_config_path, "test", lib=None) + assert config["anoption"].get() == "value" + + def test_cli_config_file_overwrites_user_defaults(self): + with open(self.user_config_path, "w") as file: + file.write("anoption: value") + + with open(self.cli_config_path, "w") as file: + file.write("anoption: cli overwrite") + self.run_command("--config", self.cli_config_path, "test", lib=None) + assert config["anoption"].get() == "cli overwrite" + + def test_cli_config_file_overwrites_beetsdir_defaults(self): + os.environ["BEETSDIR"] = str(self.beetsdir) + with open(self.env_config_path, "w") as file: + file.write("anoption: value") + + with open(self.cli_config_path, "w") as file: + file.write("anoption: cli overwrite") + self.run_command("--config", self.cli_config_path, "test", lib=None) + assert config["anoption"].get() == "cli overwrite" + + # @unittest.skip('Difficult to implement with optparse') + # def test_multiple_cli_config_files(self): + # cli_config_path_1 = os.path.join(self.temp_dir, b'config.yaml') + # cli_config_path_2 = os.path.join(self.temp_dir, b'config_2.yaml') + # + # with open(cli_config_path_1, 'w') as file: + # file.write('first: value') + # + # with open(cli_config_path_2, 'w') as file: + # file.write('second: value') + # + # self.run_command('--config', cli_config_path_1, + # '--config', cli_config_path_2, 'test', lib=None) + # assert config['first'].get() == 'value' + # assert config['second'].get() == 'value' + # + # @unittest.skip('Difficult to implement with optparse') + # def test_multiple_cli_config_overwrite(self): + # cli_overwrite_config_path = os.path.join(self.temp_dir, + # b'overwrite_config.yaml') + # + # with open(self.cli_config_path, 'w') as file: + # file.write('anoption: value') + # + # with open(cli_overwrite_config_path, 'w') as file: + # file.write('anoption: overwrite') + # + # self.run_command('--config', self.cli_config_path, + # '--config', cli_overwrite_config_path, 'test') + # assert config['anoption'].get() == 'cli overwrite' + + # FIXME: fails on windows + @unittest.skipIf(sys.platform == "win32", "win32") + def test_cli_config_paths_resolve_relative_to_user_dir(self): + with open(self.cli_config_path, "w") as file: + file.write("library: beets.db\n") + file.write("statefile: state") + + self.run_command("--config", self.cli_config_path, "test", lib=None) + assert config["library"].as_path() == self.user_config_dir / "beets.db" + assert config["statefile"].as_path() == self.user_config_dir / "state" + + def test_cli_config_paths_resolve_relative_to_beetsdir(self): + os.environ["BEETSDIR"] = str(self.beetsdir) + + with open(self.cli_config_path, "w") as file: + file.write("library: beets.db\n") + file.write("statefile: state") + + self.run_command("--config", self.cli_config_path, "test", lib=None) + assert config["library"].as_path() == self.beetsdir / "beets.db" + assert config["statefile"].as_path() == self.beetsdir / "state" + + def test_command_line_option_relative_to_working_dir(self): + config.read() + os.chdir(syspath(self.temp_dir)) + self.run_command("--library", "foo.db", "test", lib=None) + assert config["library"].as_path() == Path.cwd() / "foo.db" + + def test_cli_config_file_loads_plugin_commands(self): + with open(self.cli_config_path, "w") as file: + file.write(f"pluginpath: {_common.PLUGINPATH}\n") + file.write("plugins: test") + + self.run_command("--config", self.cli_config_path, "plugin", lib=None) + plugs = plugins.find_plugins() + assert len(plugs) == 1 + assert plugs[0].is_test_plugin + self.unload_plugins() + + def test_beetsdir_config(self): + os.environ["BEETSDIR"] = str(self.beetsdir) + + with open(self.env_config_path, "w") as file: + file.write("anoption: overwrite") + + config.read() + assert config["anoption"].get() == "overwrite" + + def test_beetsdir_points_to_file_error(self): + beetsdir = str(self.temp_dir_path / "beetsfile") + open(beetsdir, "a").close() + os.environ["BEETSDIR"] = beetsdir + with pytest.raises(ConfigError): + self.run_command("test") + + def test_beetsdir_config_does_not_load_default_user_config(self): + os.environ["BEETSDIR"] = str(self.beetsdir) + + with open(self.user_config_path, "w") as file: + file.write("anoption: value") + + config.read() + assert not config["anoption"].exists() + + def test_default_config_paths_resolve_relative_to_beetsdir(self): + os.environ["BEETSDIR"] = str(self.beetsdir) + + config.read() + assert config["library"].as_path() == self.beetsdir / "library.db" + assert config["statefile"].as_path() == self.beetsdir / "state.pickle" + + def test_beetsdir_config_paths_resolve_relative_to_beetsdir(self): + os.environ["BEETSDIR"] = str(self.beetsdir) + + with open(self.env_config_path, "w") as file: + file.write("library: beets.db\n") + file.write("statefile: state") + + config.read() + assert config["library"].as_path() == self.beetsdir / "beets.db" + assert config["statefile"].as_path() == self.beetsdir / "state" + + +class ShowModelChangeTest(IOMixin, unittest.TestCase): + def setUp(self): + super().setUp() + self.a = _common.item() + self.b = _common.item() + self.a.path = self.b.path + + def _show(self, **kwargs): + change = ui.show_model_changes(self.a, self.b, **kwargs) + out = self.io.getoutput() + return change, out + + def test_identical(self): + change, out = self._show() + assert not change + assert out == "" + + def test_string_fixed_field_change(self): + self.b.title = "x" + change, out = self._show() + assert change + assert "title" in out + + def test_int_fixed_field_change(self): + self.b.track = 9 + change, out = self._show() + assert change + assert "track" in out + + def test_floats_close_to_identical(self): + self.a.length = 1.00001 + self.b.length = 1.00005 + change, out = self._show() + assert not change + assert out == "" + + def test_floats_different(self): + self.a.length = 1.00001 + self.b.length = 2.00001 + change, out = self._show() + assert change + assert "length" in out + + def test_both_values_shown(self): + self.a.title = "foo" + self.b.title = "bar" + change, out = self._show() + assert "foo" in out + assert "bar" in out + + +class PathFormatTest(unittest.TestCase): + def test_custom_paths_prepend(self): + default_formats = ui.get_path_formats() + + config["paths"] = {"foo": "bar"} + pf = ui.get_path_formats() + key, tmpl = pf[0] + assert key == "foo" + assert tmpl.original == "bar" + assert pf[1:] == default_formats + + +@_common.slow_test() +class PluginTest(TestPluginTestCase): + def test_plugin_command_from_pluginpath(self): + self.run_command("test", lib=None) + + +class CommonOptionsParserCliTest(BeetsTestCase): + """Test CommonOptionsParser and formatting LibModel formatting on 'list' + command. + """ + + def setUp(self): + super().setUp() + self.item = _common.item() + self.item.path = b"xxx/yyy" + self.lib.add(self.item) + self.lib.add_album([self.item]) + + def test_base(self): + output = self.run_with_output("ls") + assert output == "the artist - the album - the title\n" + + output = self.run_with_output("ls", "-a") + assert output == "the album artist - the album\n" + + def test_path_option(self): + output = self.run_with_output("ls", "-p") + assert output == "xxx/yyy\n" + + output = self.run_with_output("ls", "-a", "-p") + assert output == "xxx\n" + + def test_format_option(self): + output = self.run_with_output("ls", "-f", "$artist") + assert output == "the artist\n" + + output = self.run_with_output("ls", "-a", "-f", "$albumartist") + assert output == "the album artist\n" + + def test_format_option_unicode(self): + output = self.run_with_output("ls", "-f", "caf\xe9") + assert output == "caf\xe9\n" + + def test_root_format_option(self): + output = self.run_with_output( + "--format-item", "$artist", "--format-album", "foo", "ls" + ) + assert output == "the artist\n" + + output = self.run_with_output( + "--format-item", "foo", "--format-album", "$albumartist", "ls", "-a" + ) + assert output == "the album artist\n" + + def test_help(self): + output = self.run_with_output("help") + assert "Usage:" in output + + output = self.run_with_output("help", "list") + assert "Usage:" in output + + with pytest.raises(ui.UserError): + self.run_command("help", "this.is.not.a.real.command") + + def test_stats(self): + output = self.run_with_output("stats") + assert "Approximate total size:" in output + + # # Need to have more realistic library setup for this to work + # output = self.run_with_output('stats', '-e') + # assert 'Total size:' in output + + def test_version(self): + output = self.run_with_output("version") + assert "Python version" in output + assert "no plugins loaded" in output + + # # Need to have plugin loaded + # output = self.run_with_output('version') + # assert 'plugins: ' in output + + +class CommonOptionsParserTest(unittest.TestCase): + def test_album_option(self): + parser = ui.CommonOptionsParser() + assert not parser._album_flags + parser.add_album_option() + assert bool(parser._album_flags) + + assert parser.parse_args([]) == ({"album": None}, []) + assert parser.parse_args(["-a"]) == ({"album": True}, []) + assert parser.parse_args(["--album"]) == ({"album": True}, []) + + def test_path_option(self): + parser = ui.CommonOptionsParser() + parser.add_path_option() + assert not parser._album_flags + + config["format_item"].set("$foo") + assert parser.parse_args([]) == ({"path": None}, []) + assert config["format_item"].as_str() == "$foo" + + assert parser.parse_args(["-p"]) == ( + {"path": True, "format": "$path"}, + [], + ) + assert parser.parse_args(["--path"]) == ( + {"path": True, "format": "$path"}, + [], + ) + + assert config["format_item"].as_str() == "$path" + assert config["format_album"].as_str() == "$path" + + def test_format_option(self): + parser = ui.CommonOptionsParser() + parser.add_format_option() + assert not parser._album_flags + + config["format_item"].set("$foo") + assert parser.parse_args([]) == ({"format": None}, []) + assert config["format_item"].as_str() == "$foo" + + assert parser.parse_args(["-f", "$bar"]) == ({"format": "$bar"}, []) + assert parser.parse_args(["--format", "$baz"]) == ( + {"format": "$baz"}, + [], + ) + + assert config["format_item"].as_str() == "$baz" + assert config["format_album"].as_str() == "$baz" + + def test_format_option_with_target(self): + with pytest.raises(KeyError): + ui.CommonOptionsParser().add_format_option(target="thingy") + + parser = ui.CommonOptionsParser() + parser.add_format_option(target="item") + + config["format_item"].set("$item") + config["format_album"].set("$album") + + assert parser.parse_args(["-f", "$bar"]) == ({"format": "$bar"}, []) + + assert config["format_item"].as_str() == "$bar" + assert config["format_album"].as_str() == "$album" + + def test_format_option_with_album(self): + parser = ui.CommonOptionsParser() + parser.add_album_option() + parser.add_format_option() + + config["format_item"].set("$item") + config["format_album"].set("$album") + + parser.parse_args(["-f", "$bar"]) + assert config["format_item"].as_str() == "$bar" + assert config["format_album"].as_str() == "$album" + + parser.parse_args(["-a", "-f", "$foo"]) + assert config["format_item"].as_str() == "$bar" + assert config["format_album"].as_str() == "$foo" + + parser.parse_args(["-f", "$foo2", "-a"]) + assert config["format_album"].as_str() == "$foo2" + + def test_add_all_common_options(self): + parser = ui.CommonOptionsParser() + parser.add_all_common_options() + assert parser.parse_args([]) == ( + {"album": None, "path": None, "format": None}, + [], + ) + + +class EncodingTest(unittest.TestCase): + """Tests for the `terminal_encoding` config option and our + `_in_encoding` and `_out_encoding` utility functions. + """ + + def out_encoding_overridden(self): + config["terminal_encoding"] = "fake_encoding" + assert ui._out_encoding() == "fake_encoding" + + def in_encoding_overridden(self): + config["terminal_encoding"] = "fake_encoding" + assert ui._in_encoding() == "fake_encoding" + + def out_encoding_default_utf8(self): + with patch("sys.stdout") as stdout: + stdout.encoding = None + assert ui._out_encoding() == "utf-8" + + def in_encoding_default_utf8(self): + with patch("sys.stdin") as stdin: + stdin.encoding = None + assert ui._in_encoding() == "utf-8" diff --git a/test/test_ui_importer.py b/test/ui/test_ui_importer.py similarity index 100% rename from test/test_ui_importer.py rename to test/ui/test_ui_importer.py diff --git a/test/test_ui_init.py b/test/ui/test_ui_init.py similarity index 100% rename from test/test_ui_init.py rename to test/ui/test_ui_init.py