diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index fe980bb5c..cf2162337 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1111,76 +1111,9 @@ def show_model_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. @@ -1666,7 +1599,7 @@ def _raw_main(args: list[str], lib=None) -> None: 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() diff --git a/beets/ui/commands/__init__.py b/beets/ui/commands/__init__.py index 0691be045..214bcfbd0 100644 --- a/beets/ui/commands/__init__.py +++ b/beets/ui/commands/__init__.py @@ -32,6 +32,21 @@ from .update import update_cmd from .version import version_cmd from .write import write_cmd + +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", + ) + + # The list of default subcommands. This is populated with Subcommand # objects that can be fed to a SubcommandsOptionParser. default_commands = [ diff --git a/beets/ui/commands/_utils.py b/beets/ui/commands/_utils.py deleted file mode 100644 index 17e2f34c8..000000000 --- a/beets/ui/commands/_utils.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Utility functions for beets UI commands.""" - -import os - -from beets import ui -from beets.util import displayable_path, normpath, syspath - - -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 {displayable_path(logfile)}: {err}" - ) from err - except OSError as err: - raise ui.UserError( - f"unreadable logfile {displayable_path(logfile)}: {err}" - ) from err diff --git a/beets/ui/commands/completion.py b/beets/ui/commands/completion.py index 266b2740a..776c389b4 100644 --- a/beets/ui/commands/completion.py +++ b/beets/ui/commands/completion.py @@ -48,7 +48,7 @@ def completion_script(commands): completion data for. """ base_script = os.path.join( - os.path.dirname(__file__), "../completion_base.sh" + os.path.dirname(__file__), "./completion_base.sh" ) with open(base_script) as base_script: yield base_script.read() diff --git a/beets/ui/commands/config.py b/beets/ui/commands/config.py index 81cc2851a..3581c6647 100644 --- a/beets/ui/commands/config.py +++ b/beets/ui/commands/config.py @@ -2,7 +2,8 @@ import os -from beets import config, ui, util +from beets import config, ui +from beets.util import displayable_path, editor_command, interactive_open def config_func(lib, opts, args): @@ -25,7 +26,7 @@ def config_func(lib, opts, args): filenames.insert(0, user_path) for filename in filenames: - ui.print_(util.displayable_path(filename)) + ui.print_(displayable_path(filename)) # Open in editor. elif opts.edit: @@ -45,11 +46,11 @@ def config_edit(): An empty config file is created if no existing config file exists. """ path = config.user_config_path() - editor = util.editor_command() + editor = editor_command() try: if not os.path.isfile(path): open(path, "w+").close() - util.interactive_open([path], editor) + interactive_open([path], editor) except OSError as exc: message = f"Could not edit configuration: {exc}" if not editor: diff --git a/beets/ui/commands/import_/__init__.py b/beets/ui/commands/import_/__init__.py index 6940528ad..5dba71fa8 100644 --- a/beets/ui/commands/import_/__init__.py +++ b/beets/ui/commands/import_/__init__.py @@ -5,13 +5,47 @@ import os from beets import config, logging, plugins, ui from beets.util import displayable_path, normpath, syspath -from .._utils import parse_logfiles from .session import TerminalImportSession # Global logger. log = logging.getLogger("beets") +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 {displayable_path(logfile)}: {err}" + ) from err + except OSError as err: + raise ui.UserError( + f"unreadable logfile {displayable_path(logfile)}: {err}" + ) from err + + def import_files(lib, paths: list[bytes], query): """Import the files in the given list of paths or matching the query. @@ -97,6 +131,32 @@ def import_func(lib, opts, args: list[str]): import_files(lib, byte_paths, query) +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 ui.UserError( + f"supplied argument `{value}' is not of the form `key=value'" + ) + + option_values[key] = value + + import_cmd = ui.Subcommand( "import", help="import new music", aliases=("imp", "im") ) @@ -274,7 +334,7 @@ import_cmd.parser.add_option( "--set", dest="set_fields", action="callback", - callback=ui._store_dict, + callback=_store_dict, metavar="FIELD=VALUE", help="set the given fields to the supplied values", ) diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py index b6617d487..a12f1f8d3 100644 --- a/beets/ui/commands/import_/display.py +++ b/beets/ui/commands/import_/display.py @@ -2,16 +2,13 @@ import os from collections.abc import Sequence from functools import cached_property -from beets import autotag, config, logging, ui +from beets import autotag, config, ui from beets.autotag import hooks from beets.util import displayable_path from beets.util.units import human_seconds_short VARIOUS_ARTISTS = "Various Artists" -# Global logger. -log = logging.getLogger("beets") - class ChangeRepresentation: """Keeps track of all information needed to generate a (colored) text diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index 6608705a8..27562664e 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -6,7 +6,6 @@ from beets import autotag, config, importer, logging, plugins, ui from beets.autotag import Recommendation from beets.util import displayable_path from beets.util.units import human_bytes, human_seconds_short -from beetsplug.bareasc import print_ from .display import ( disambig_string, @@ -415,8 +414,8 @@ def choose_candidate( if singleton: ui.print_("No matching recordings found.") else: - print_(f"No matching release found for {itemcount} tracks.") - print_( + ui.print_(f"No matching release found for {itemcount} tracks.") + ui.print_( "For help, see: " "https://beets.readthedocs.org/en/latest/faq.html#nomatch" ) @@ -461,17 +460,17 @@ def choose_candidate( else: metadata = ui.colorize("text_highlight_minor", metadata) line1 = [index, distance, metadata] - print_(f" {' '.join(line1)}") + ui.print_(f" {' '.join(line1)}") # Penalties. penalties = penalty_string(match.distance, 3) if penalties: - print_(f"{' ' * 13}{penalties}") + ui.print_(f"{' ' * 13}{penalties}") # Disambiguation disambig = disambig_string(match.info) if disambig: - print_(f"{' ' * 13}{disambig}") + ui.print_(f"{' ' * 13}{disambig}") # Ask the user for a choice. sel = ui.input_options(choice_opts, numrange=(1, len(candidates))) diff --git a/beets/ui/commands/modify.py b/beets/ui/commands/modify.py index dab68a3fc..186bfb6dd 100644 --- a/beets/ui/commands/modify.py +++ b/beets/ui/commands/modify.py @@ -3,7 +3,7 @@ from beets import library, ui from beets.util import functemplate -from ._utils import do_query +from .utils import do_query def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): diff --git a/beets/ui/commands/move.py b/beets/ui/commands/move.py index 6d6f4f16a..40a9d1b83 100644 --- a/beets/ui/commands/move.py +++ b/beets/ui/commands/move.py @@ -2,17 +2,65 @@ import os -from beets import logging, ui, util +from beets import logging, ui +from beets.util import ( + MoveOperation, + PathLike, + displayable_path, + normpath, + syspath, +) -from ._utils import do_query +from .utils import do_query # Global logger. log = logging.getLogger("beets") +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(displayable_path, sources)) + destinations = list(map(displayable_path, destinations)) + + # Calculate widths for terminal split + col_width = (ui.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 = ui.colordiff(source, dest) + ui.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(" -> ") + + ui.print_(f"Source {' ' * title_pad} Destination") + for source, dest in zip(sources, destinations): + pad = max_width - len(source) + color_source, color_dest = ui.colordiff(source, dest) + ui.print_(f"{color_source} {' ' * pad} -> {color_dest}") + + def move_items( lib, - dest_path: util.PathLike, + dest_path: PathLike, query, copy, album, @@ -60,7 +108,7 @@ def move_items( if pretend: if album: - ui.show_path_changes( + show_path_changes( [ (item.path, item.destination(basedir=dest)) for obj in objs @@ -68,7 +116,7 @@ def move_items( ] ) else: - ui.show_path_changes( + show_path_changes( [(obj.path, obj.destination(basedir=dest)) for obj in objs] ) else: @@ -76,7 +124,7 @@ def move_items( objs = ui.input_select_objects( f"Really {act}", objs, - lambda o: ui.show_path_changes( + lambda o: show_path_changes( [(o.path, o.destination(basedir=dest))] ), ) @@ -87,24 +135,22 @@ def move_items( if export: # Copy without affecting the database. obj.move( - operation=util.MoveOperation.COPY, basedir=dest, store=False + operation=MoveOperation.COPY, basedir=dest, store=False ) else: # Ordinary move/copy: store the new path. if copy: - obj.move(operation=util.MoveOperation.COPY, basedir=dest) + obj.move(operation=MoveOperation.COPY, basedir=dest) else: - obj.move(operation=util.MoveOperation.MOVE, basedir=dest) + obj.move(operation=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 ui.UserError( - f"no such directory: {util.displayable_path(dest)}" - ) + dest = normpath(dest) + if not os.path.isdir(syspath(dest)): + raise ui.UserError(f"no such directory: {displayable_path(dest)}") move_items( lib, diff --git a/beets/ui/commands/remove.py b/beets/ui/commands/remove.py index 574f0c4d4..997a4b48c 100644 --- a/beets/ui/commands/remove.py +++ b/beets/ui/commands/remove.py @@ -2,7 +2,7 @@ from beets import ui -from ._utils import do_query +from .utils import do_query def remove_items(lib, query, album, delete, force): diff --git a/beets/ui/commands/update.py b/beets/ui/commands/update.py index 71be6bbd9..9286bf12b 100644 --- a/beets/ui/commands/update.py +++ b/beets/ui/commands/update.py @@ -5,7 +5,7 @@ import os from beets import library, logging, ui from beets.util import ancestry, syspath -from ._utils import do_query +from .utils import do_query # Global logger. log = logging.getLogger("beets") diff --git a/beets/ui/commands/utils.py b/beets/ui/commands/utils.py new file mode 100644 index 000000000..71c104d07 --- /dev/null +++ b/beets/ui/commands/utils.py @@ -0,0 +1,29 @@ +"""Utility functions for beets UI commands.""" + +from beets import ui + + +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 diff --git a/beets/ui/commands/write.py b/beets/ui/commands/write.py index 84f2fb5b6..05c3c7565 100644 --- a/beets/ui/commands/write.py +++ b/beets/ui/commands/write.py @@ -5,7 +5,7 @@ import os from beets import library, logging, ui from beets.util import syspath -from ._utils import do_query +from .utils import do_query # Global logger. log = logging.getLogger("beets") diff --git a/beetsplug/edit.py b/beetsplug/edit.py index f6fadefd0..188afed1f 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -25,7 +25,8 @@ import yaml from beets import plugins, ui, util from beets.dbcore import types from beets.importer import Action -from beets.ui.commands import PromptChoice, _do_query +from beets.ui.commands.import_.session import PromptChoice +from beets.ui.commands.utils import do_query # 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 @@ class EditPlugin(plugins.BeetsPlugin): 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.")