From dbd2fda4fffc87e956b4ce9139305abd626b187f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 17 Aug 2026 21:18:21 +0100 Subject: [PATCH 1/4] typing: add missing core annotations --- beets/__init__.py | 4 +- beets/autotag/__init__.py | 3 +- beets/autotag/distance.py | 28 ++++---- beets/autotag/match.py | 2 +- beets/context.py | 8 ++- beets/dbcore/db.py | 4 +- beets/library/__init__.py | 4 +- beets/logging.py | 22 +++--- beets/metadata_plugins.py | 4 +- beets/plugins.py | 4 +- beets/test/_common.py | 22 ++++-- beets/test/fixtures.py | 8 +-- beets/test/helper.py | 2 +- beets/ui/__init__.py | 124 ++++++++++++++++++++------------ beets/ui/commands/__init__.py | 11 ++- beets/ui/commands/completion.py | 11 ++- beets/ui/commands/config.py | 2 +- beets/ui/commands/fields.py | 6 +- beets/ui/commands/help.py | 2 +- beets/ui/commands/list.py | 4 +- beets/ui/commands/modify.py | 20 +++++- beets/ui/commands/move.py | 24 +++---- beets/ui/commands/remove.py | 10 +-- beets/ui/commands/stats.py | 2 +- beets/ui/commands/update.py | 10 ++- beets/ui/commands/utils.py | 11 ++- beets/ui/commands/version.py | 2 +- beets/ui/commands/write.py | 4 +- beets/util/config.py | 2 +- beets/util/extension.py | 4 +- beets/util/m3u.py | 10 +-- beets/util/pipeline.py | 7 +- beets/util/units.py | 6 +- 33 files changed, 250 insertions(+), 137 deletions(-) diff --git a/beets/__init__.py b/beets/__init__.py index 08f7b6e36c..f375712bdc 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from sys import stderr -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import confuse @@ -14,7 +14,7 @@ __author__ = "Adrian Sampson " -def __getattr__(name: str): +def __getattr__(name: str) -> Any: """Handle deprecated imports.""" return deprecate_imports( __name__, {"art": "beetsplug._utils", "vfs": "beetsplug._utils"}, name diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index dc3d1a62fc..cccaef0558 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from importlib import import_module +from typing import Any # Parts of external interface. from beets.util.deprecation import deprecate_for_maintainers, deprecate_imports @@ -22,7 +23,7 @@ from .source import Source -def __getattr__(name: str): +def __getattr__(name: str) -> Any: if name == "current_metadata": deprecate_for_maintainers( f"'beets.autotag.{name}'", "'beets.util.get_most_common_tags'" diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index e7e266a639..52a51c52f9 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -208,21 +208,21 @@ def items(self) -> list[tuple[str, float]]: def __hash__(self) -> int: return id(self) - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: return self.distance == other # Behave like a float. - def __lt__(self, other) -> bool: + def __lt__(self, other: object) -> bool: return self.distance < other def __float__(self) -> float: return self.distance - def __sub__(self, other) -> float: + def __sub__(self, other: object) -> float: return self.distance - other - def __rsub__(self, other) -> float: + def __rsub__(self, other: object) -> float: return other - self.distance def __str__(self) -> str: @@ -230,7 +230,7 @@ def __str__(self) -> str: # Behave like a dict. - def __getitem__(self, key) -> float: + def __getitem__(self, key: str) -> float: """Returns the weighted distance for a named penalty.""" dist = sum(self._penalties[key]) * self._weights[key] dist_max = self.max_distance @@ -247,7 +247,7 @@ def __len__(self) -> int: def keys(self) -> KeysView[str]: return dict.fromkeys(key for key, _ in self.items()).keys() - def update(self, dist: Distance): + def update(self, dist: Distance) -> None: """Adds all the distance penalties from `dist`.""" if not isinstance(dist, Distance): raise ValueError( @@ -267,7 +267,7 @@ def _eq(self, value1: re.Pattern[str] | Any, value2: Any) -> bool: return bool(value1.match(value2)) return value1 == value2 - def add(self, key: str, dist: float): + def add(self, key: str, dist: float) -> None: """Adds a distance penalty. `key` must correspond with a configured weight setting. `dist` must be a float between 0.0 and 1.0, and will be added to any existing distance penalties @@ -279,7 +279,7 @@ def add(self, key: str, dist: float): def add_equality( self, key: str, value: Any, options: list[Any] | tuple[Any, ...] | Any - ): + ) -> None: """Adds a distance penalty of 1.0 if `value` doesn't match any of the values in `options`. If an option is a compiled regular expression, it will be considered equal if it matches against @@ -295,7 +295,7 @@ def add_equality( dist = 1.0 self.add(key, dist) - def add_expr(self, key: str, expr: bool): + def add_expr(self, key: str, expr: bool) -> None: """Adds a distance penalty of 1.0 if `expr` evaluates to True, or 0.0. """ @@ -304,7 +304,7 @@ def add_expr(self, key: str, expr: bool): else: self.add(key, 0.0) - def add_number(self, key: str, number1: int, number2: int): + def add_number(self, key: str, number1: int, number2: int) -> None: """Adds a distance penalty of 1.0 for each number of difference between `number1` and `number2`, or 0.0 when there is no difference. Use this when there is no upper limit on the @@ -319,7 +319,7 @@ def add_number(self, key: str, number1: int, number2: int): def add_priority( self, key: str, value: Any, options: list[Any] | tuple[Any, ...] | Any - ): + ) -> None: """Adds a distance penalty that corresponds to the position at which `value` appears in `options`. A distance penalty of 0.0 for the first option, or 1.0 if there is no matching option. If @@ -337,7 +337,9 @@ def add_priority( dist = 1.0 self.add(key, dist) - def add_ratio(self, key: str, number1: int | float, number2: int | float): + def add_ratio( + self, key: str, number1: int | float, number2: int | float + ) -> None: """Adds a distance penalty for `number1` as a ratio of `number2`. `number1` is bound at 0 and `number2`. """ @@ -348,7 +350,7 @@ def add_ratio(self, key: str, number1: int | float, number2: int | float): dist = 0.0 self.add(key, dist) - def add_string(self, key: str, str1: str | None, str2: str | None): + def add_string(self, key: str, str1: str | None, str2: str | None) -> None: """Adds a distance penalty based on the edit distance between `str1` and `str2`. """ diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 6bd8635a9d..c5ee5451e3 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -330,7 +330,7 @@ def _sort_candidates(candidates: Iterable[AnyMatch]) -> Sequence[AnyMatch]: def _add_candidate( source: Source, results: Candidates[AlbumMatch], info: AlbumInfo -): +) -> None: """Given a candidate AlbumInfo object, attempt to add the candidate to the output dictionary of AlbumMatch objects. This involves checking the track count, ordering the items, checking for diff --git a/beets/context.py b/beets/context.py index 5d56831a1b..e34e07c02a 100644 --- a/beets/context.py +++ b/beets/context.py @@ -1,5 +1,11 @@ +from __future__ import annotations + from contextlib import contextmanager from contextvars import ContextVar +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator # Holds the music dir context _music_dir_var: ContextVar[bytes] = ContextVar("music_dir", default=b"") @@ -16,7 +22,7 @@ def set_music_dir(value: bytes) -> None: @contextmanager -def music_dir(value: bytes): +def music_dir(value: bytes) -> Iterator[None]: """Temporarily bind the active music directory for query parsing.""" token = _music_dir_var.set(value) try: diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 9542f43730..e1dd07e94b 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -653,7 +653,7 @@ def remove(self) -> None: f"DELETE FROM {self._flex_table} WHERE entity_id=?", (self.id,) ) - def add(self, db: D | None = None): + def add(self, db: D | None = None) -> None: """Add the object to the library database. This object must be associated with a database; you can provide one via the `db` parameter or use the currently associated database. @@ -681,7 +681,7 @@ def add(self, db: D | None = None): def formatted( self, - included_keys: str = FormattedMapping.ALL_KEYS, + included_keys: str | list[str] = FormattedMapping.ALL_KEYS, for_path: bool = False, ) -> FormattedMapping: """Get a mapping containing all values on this object formatted diff --git a/beets/library/__init__.py b/beets/library/__init__.py index 5b10be1f98..c69991e4a9 100644 --- a/beets/library/__init__.py +++ b/beets/library/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from beets.util.deprecation import deprecate_imports from .exceptions import FileOperationError, ReadError, WriteError @@ -12,7 +14,7 @@ ) -def __getattr__(name: str): +def __getattr__(name: str) -> Any: return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name) diff --git a/beets/logging.py b/beets/logging.py index 88d65e7317..c4d53e178c 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: from collections.abc import Mapping - from logging import RootLogger + from logging import LogRecord, RootLogger from types import TracebackType T = TypeVar("T") @@ -113,7 +113,7 @@ class LegacyFormatter(Formatter): "beets.musicbrainz.sub" "musicbrainz.sub: msg" """ - def format(self, record): + def format(self, record: LogRecord) -> str: parts = record.name.split(".") record.legacy_prefix = ( f"{'.'.join(parts[1:])}: " if len(parts) > 1 else "" @@ -134,12 +134,14 @@ class StrFormatLogger(Logger): """ class _LogMessage: - def __init__(self, msg: str, args: _ArgsType, kwargs: dict[str, Any]): + def __init__( + self, msg: str, args: _ArgsType, kwargs: dict[str, Any] + ) -> None: self.msg = msg self.args = args self.kwargs = kwargs - def __str__(self): + def __str__(self) -> str: args = [_logsafe(a) for a in self.args] kwargs = {k: _logsafe(v) for (k, v) in self.kwargs.items()} return self.msg.format(*args, **kwargs) @@ -154,7 +156,7 @@ def _log( stack_info: bool = False, stacklevel: int = 2, **kwargs, - ): + ) -> None: """Log msg.format(*args, **kwargs)""" if isinstance(msg, str): @@ -174,13 +176,13 @@ def _log( class ThreadLocalLevelLogger(Logger): """A version of `Logger` whose level is thread-local instead of shared.""" - def __init__(self, name, level=NOTSET): + def __init__(self, name: str, level: int = NOTSET) -> None: self._thread_level = threading.local() self.default_level = NOTSET super().__init__(name, level) @property - def level(self): + def level(self) -> int: try: return self._thread_level.level except AttributeError: @@ -188,10 +190,10 @@ def level(self): return self.level @level.setter - def level(self, value): + def level(self, value: int) -> None: self._thread_level.level = value - def set_global_level(self, level): + def set_global_level(self, level: int) -> None: """Set the level on the current thread + the default value for all threads. """ @@ -223,7 +225,7 @@ def extra_debug(self, msg: str, *args: Any, **kwargs: Any) -> None: def getLogger(name: str) -> BeetsLogger: ... @overload def getLogger(name: None = ...) -> RootLogger: ... -def getLogger(name=None) -> BeetsLogger | RootLogger: # noqa: N802 +def getLogger(name: str | None = None) -> BeetsLogger | RootLogger: # noqa: N802 if name: return my_manager.getLogger(name) # type: ignore[return-value] return Logger.root diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 124d4ab4e8..5ecb800f9d 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -60,7 +60,9 @@ def get_metadata_source(name: str) -> MetadataSourcePlugin | None: @contextmanager -def maybe_handle_plugin_error(plugin: MetadataSourcePlugin, method_name: str): +def maybe_handle_plugin_error( + plugin: MetadataSourcePlugin, method_name: str +) -> Iterator[None]: """Safely call a plugin method, catching and logging exceptions.""" if config["raise_on_error"]: yield diff --git a/beets/plugins.py b/beets/plugins.py index 6ac228c832..880a82bda7 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -73,7 +73,7 @@ class PluginImportError(ImportError): from other errors. """ - def __init__(self, name: str): + def __init__(self, name: str) -> None: super().__init__(f"Could not import plugin {name}") @@ -162,7 +162,7 @@ def __init_subclass__(cls) -> None: ): setattr(cls, name, method) - def __init__(self, name: str | None = None): + def __init__(self, name: str | None = None) -> None: """Perform one-time plugin setup.""" self.name = name or self.__module__.split(".")[-1] diff --git a/beets/test/_common.py b/beets/test/_common.py index 54e674d358..23091e6f3c 100644 --- a/beets/test/_common.py +++ b/beets/test/_common.py @@ -17,8 +17,12 @@ from beets.util import syspath if TYPE_CHECKING: + from collections.abc import Iterator + import pytest + from beets.library import Item, Library + # Test resources path. RSRC = (Path(__file__).parent.parent.parent / "test" / "rsrc").resolve() PLUGINPATH = str(RSRC / "beetsplug") @@ -33,7 +37,7 @@ HAVE_HARDLINK = sys.platform != "win32" -def item(lib=None, **kwargs): +def item(lib: Library | None = None, **kwargs) -> Item: defaults = dict( title="the title", artist="the artist", @@ -76,7 +80,13 @@ def item(lib=None, **kwargs): # Dummy import session. -def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False): +def import_session( + lib: Library | None = None, + loghandler: logging.Handler | None = None, + paths: list[bytes] = [], + query: list[str] = [], + cli: bool = False, +) -> importer.ImportSession: cls = ( commands.import_.session.TerminalImportSession if cli @@ -142,7 +152,7 @@ def getoutput(self) -> str: # Utility. -def touch(path): +def touch(path: bytes) -> None: open(syspath(path), "a").close() @@ -150,7 +160,7 @@ def touch(path): @contextmanager -def platform_windows(): +def platform_windows() -> Iterator[None]: import ntpath old_path = os.path @@ -162,7 +172,7 @@ def platform_windows(): @contextmanager -def platform_posix(): +def platform_posix() -> Iterator[None]: import posixpath old_path = os.path @@ -174,7 +184,7 @@ def platform_posix(): @contextmanager -def system_mock(name): +def system_mock(name: str) -> Iterator[None]: import platform old_system = platform.system diff --git a/beets/test/fixtures.py b/beets/test/fixtures.py index ba32595322..28a650504e 100644 --- a/beets/test/fixtures.py +++ b/beets/test/fixtures.py @@ -4,7 +4,7 @@ exercise metadata registration and model integration. """ -from typing import ClassVar +from typing import Any, ClassVar from beets.dbcore import Index, sort, types from beets.dbcore.db import FormattedMapping @@ -33,11 +33,11 @@ class ModelFixture1(LibModel): _formatter = FormattedMapping @cached_classproperty - def _types(cls): + def _types(cls) -> dict[str, types.Type]: return {"some_float_field": types.FLOAT} @classmethod - def _getters(cls): + def _getters(cls) -> dict[str, Any]: return {} @@ -47,7 +47,7 @@ class DummyIMBackend(IMBackend): The version is sufficiently recent to support image comparison. """ - def __init__(self): + def __init__(self) -> None: """Init a dummy backend class for mocked ImageMagick tests.""" self.version = (7, 0, 0) self.legacy = False diff --git a/beets/test/helper.py b/beets/test/helper.py index 8ec8624ad4..4d7f614aa9 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -680,7 +680,7 @@ def choose_match( class TerminalImportSessionFixture(TerminalImportSession): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: self.io = kwargs.pop("io") super().__init__(*args, **kwargs) self._choices = [] diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 4f604b4e3f..cee8eeb9a9 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -14,7 +14,7 @@ import textwrap import traceback from functools import cache -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TextIO import confuse @@ -28,7 +28,11 @@ from beets.util.diff import get_model_changes if TYPE_CHECKING: - from collections.abc import Callable, Iterable + from collections.abc import Callable, Iterable, Sequence + from pathlib import Path + + from beets.library import LibModel + from beets.util.color import ColorName # On Windows platforms, use colorama to support "ANSI" terminal colors. @@ -47,17 +51,17 @@ # Encoding utilities. -def _in_encoding(): +def _in_encoding() -> str: """Get the encoding to use for *inputting* strings from the console.""" return _stream_encoding(sys.stdin) -def _out_encoding(): +def _out_encoding() -> str: """Get the encoding to use for *outputting* strings to the console.""" return _stream_encoding(sys.stdout) -def _stream_encoding(stream, default="utf-8"): +def _stream_encoding(stream: TextIO, 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. @@ -78,7 +82,7 @@ def _stream_encoding(stream, default="utf-8"): return stream.encoding or default -def decargs(arglist): +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. @@ -117,7 +121,7 @@ def print_(*strings: str, end: str = "\n") -> None: # Configuration wrappers. -def _bool_fallback(a, b): +def _bool_fallback(a: bool | None, b: bool) -> bool: """Given a boolean or None, return the original value or a fallback.""" if a is None: assert isinstance(b, bool) @@ -126,14 +130,14 @@ def _bool_fallback(a, b): return a -def should_write(write_opt=None): +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=None): +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. @@ -153,7 +157,7 @@ def should_move(move_opt=None): # Input prompts. -def input_(prompt=None): +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 @@ -174,14 +178,14 @@ def input_(prompt=None): def input_options( - options, - require=False, - prompt=None, - fallback_prompt=None, - numrange=None, - default=None, - max_width=72, -): + options: Sequence[str], + require: bool = False, + prompt: str | None = None, + fallback_prompt: str | None = None, + numrange: tuple[int, int] | None = None, + default: str | None = None, + max_width: int = 72, +) -> Any: """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 @@ -247,7 +251,9 @@ def input_options( ) # Insert the highlighted letter back into the word. - descr_color = "action_default" if is_default else "action_description" + descr_color: ColorName = ( + "action_default" if is_default else "action_description" + ) capitalized.append( colorize(descr_color, option[:index]) + show_letter @@ -348,7 +354,7 @@ def input_options( resp = input_(fallback_prompt) -def input_yn(prompt, require=False): +def input_yn(prompt: str, 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. """ @@ -360,7 +366,12 @@ def input_yn(prompt, require=False): return sel == "y" -def input_select_objects(prompt, objs, rep, prompt_all=None): +def input_select_objects( + prompt: str, + objs: Sequence[LibModel], + rep: Callable[[LibModel], Any], + prompt_all: str | None = None, +) -> Any: """Prompt to user to choose all, none, or some of the given objects. Return the list of selected objects. @@ -453,14 +464,16 @@ class CommonOptionsParser(optparse.OptionParser): Each method is fully documented in the related method. """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: 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")): + def add_album_option( + self, flags: Sequence[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 @@ -475,14 +488,14 @@ def add_album_option(self, flags=("-a", "--album")): def _set_format( self, - option, - opt_str, - value, - parser, - target=None, - fmt=None, - store_true=False, - ): + option: optparse.Option, + opt_str: str, + value: str, + parser: SubcommandsOptionParser, + target: type[LibModel] | None = None, + fmt: str | None = None, + store_true: bool = False, + ) -> None: """Internal callback that sets the correct format while parsing CLI arguments. """ @@ -510,7 +523,7 @@ def _set_format( config[library.Item._format_config_key].set(value) config[library.Album._format_config_key].set(value) - def add_path_option(self, flags=("-p", "--path")): + def add_path_option(self, flags: Sequence[str] = ("-p", "--path")) -> None: """Add a -p/--path option to display the path instead of the default format. @@ -530,7 +543,11 @@ def add_path_option(self, flags=("-p", "--path")): ) self.add_option(path) - def add_format_option(self, flags=("-f", "--format"), target=None): + def add_format_option( + self, + flags: Sequence[str] = ("-f", "--format"), + target: str | None = None, + ) -> None: """Add -f/--format option to print some LibModel instances with a custom format. @@ -560,7 +577,7 @@ def add_format_option(self, flags=("-f", "--format"), target=None): ) self.add_option(opt) - def add_all_common_options(self): + def add_all_common_options(self) -> None: """Add album, path and format options.""" self.add_album_option() self.add_path_option() @@ -583,7 +600,14 @@ class Subcommand: func: Callable[[library.Library, optparse.Values, list[str]], Any] - def __init__(self, name, parser=None, help="", aliases=(), hide=False): # noqa: A002 + def __init__( + self, + name: str, + parser: CommonOptionsParser | None = None, + help: str = "", # noqa: A002 + aliases: Sequence[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. @@ -597,18 +621,18 @@ def __init__(self, name, parser=None, help="", aliases=(), hide=False): # noqa: self.hide = hide self._root_parser = None - def print_help(self): + def print_help(self) -> None: self.parser.print_help() - def parse_args(self, args): + def parse_args(self, args: list[str]) -> tuple[optparse.Values, list[str]]: return self.parser.parse_args(args) @property - def root_parser(self): + def root_parser(self) -> optparse.OptionParser | None: return self._root_parser @root_parser.setter - def root_parser(self, root_parser): + 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}" @@ -620,7 +644,7 @@ class SubcommandsOptionParser(CommonOptionsParser): arguments. """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> 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. @@ -640,14 +664,16 @@ def __init__(self, *args, **kwargs): self.subcommands = [] - def add_subcommand(self, *cmds): + def add_subcommand(self, *cmds) -> 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. - def format_help(self, formatter=None): + def format_help( + self, formatter: optparse.HelpFormatter | None = None + ) -> str: # Get the original help message, to which we will append. out = super().format_help(formatter) if formatter is None: @@ -699,7 +725,7 @@ def format_help(self, formatter=None): # list. return f"{out}{''.join(result)}" - def _subcommand_for_name(self, name): + 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. @@ -709,7 +735,9 @@ def _subcommand_for_name(self, name): return subcommand return None - def parse_global_options(self, args): + def parse_global_options( + self, args: list[str] + ) -> tuple[optparse.Values, list[str]]: """Parse options up to the subcommand argument. Returns a tuple of the options object and the remaining arguments. """ @@ -722,7 +750,9 @@ def parse_global_options(self, args): subargs = ["version"] return options, subargs - def parse_subcommand(self, args): + def parse_subcommand( + self, args: list[str] + ) -> tuple[Subcommand, optparse.Values, list[str]]: """Given the `args` left unused by a `parse_global_options`, return the invoked subcommand, the subcommand options, and the subcommand arguments. @@ -766,7 +796,7 @@ def _setup() -> tuple[list[Subcommand], library.Library]: return subcommands, lib -def _ensure_db_directory_exists(path): +def _ensure_db_directory_exists(path: Path) -> None: dbpath = os.fspath(path) if dbpath in (":memory:", b":memory:"): # in memory db return @@ -831,7 +861,7 @@ def _raw_main(args: list[str] | None) -> None: def parse_csl_callback( option: optparse.Option, _, value: str, parser: SubcommandsOptionParser - ): + ) -> None: """Parse a comma-separated list of values.""" setattr( parser.values, diff --git a/beets/ui/commands/__init__.py b/beets/ui/commands/__init__.py index e32c2dd293..7b0ad75bec 100644 --- a/beets/ui/commands/__init__.py +++ b/beets/ui/commands/__init__.py @@ -2,6 +2,10 @@ interface. """ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from beets.util.deprecation import deprecate_imports from .completion import completion_cmd @@ -18,8 +22,11 @@ from .version import version_cmd from .write import write_cmd +if TYPE_CHECKING: + from beets.ui import Subcommand + -def __getattr__(name: str): +def __getattr__(name: str) -> Any: """Handle deprecated imports.""" return deprecate_imports( __name__, @@ -33,7 +40,7 @@ def __getattr__(name: str): # The list of default subcommands. This is populated with Subcommand # objects that can be fed to a SubcommandsOptionParser. -default_commands = [ +default_commands: list[Subcommand] = [ fields_cmd, HelpCommand(), import_cmd, diff --git a/beets/ui/commands/completion.py b/beets/ui/commands/completion.py index 776c389b4c..db739deecd 100644 --- a/beets/ui/commands/completion.py +++ b/beets/ui/commands/completion.py @@ -1,16 +1,23 @@ """The 'completion' command: print shell script for command line completion.""" +from __future__ import annotations + import os import re +from typing import TYPE_CHECKING from beets import library, logging, plugins, ui from beets.util import syspath +if TYPE_CHECKING: + from collections.abc import Iterator, Sequence + + # Global logger. log = logging.getLogger("beets") -def print_completion(*args): +def print_completion(*args) -> None: from beets.ui.commands import default_commands for line in completion_script(default_commands + plugins.commands()): @@ -41,7 +48,7 @@ def print_completion(*args): ] -def completion_script(commands): +def completion_script(commands: Sequence[ui.Subcommand]) -> Iterator[str]: """Yield the full completion shell script as strings. ``commands`` is alist of ``ui.Subcommand`` instances to generate diff --git a/beets/ui/commands/config.py b/beets/ui/commands/config.py index e3120b3652..c2b413568a 100644 --- a/beets/ui/commands/config.py +++ b/beets/ui/commands/config.py @@ -53,7 +53,7 @@ def config_func(lib: Library, opts: optparse.Values, args: list[str]) -> None: print("Empty configuration") -def config_edit(cli_options): +def config_edit(cli_options: optparse.Values) -> None: """Open a program to edit the user configuration. An empty config file is created if no existing config file exists. """ diff --git a/beets/ui/commands/fields.py b/beets/ui/commands/fields.py index 792f794bda..b0d7286c65 100644 --- a/beets/ui/commands/fields.py +++ b/beets/ui/commands/fields.py @@ -9,11 +9,13 @@ if TYPE_CHECKING: import optparse + import sqlite3 + from collections.abc import Iterable from beets.library import Library -def _print_keys(query): +def _print_keys(query: list[sqlite3.Row]) -> None: """Given a SQLite query result, print the `key` field of each returned row, with indentation of 2 spaces. """ @@ -22,7 +24,7 @@ def _print_keys(query): def fields_func(lib: Library, opts: optparse.Values, args: list[str]) -> None: - def _print_rows(names): + def _print_rows(names: Iterable[str]) -> None: ui.print_(textwrap.indent("\n".join(sorted(names)), " ")) ui.print_("Item fields:") diff --git a/beets/ui/commands/help.py b/beets/ui/commands/help.py index 76dbcb66f0..fecc32a9d3 100644 --- a/beets/ui/commands/help.py +++ b/beets/ui/commands/help.py @@ -14,7 +14,7 @@ class HelpCommand(ui.Subcommand): - def __init__(self): + def __init__(self) -> None: super().__init__( "help", aliases=("?",), diff --git a/beets/ui/commands/list.py b/beets/ui/commands/list.py index 4389f42140..0c8c311aaa 100644 --- a/beets/ui/commands/list.py +++ b/beets/ui/commands/list.py @@ -12,7 +12,9 @@ from beets.library import Library -def list_items(lib, query, album, fmt=""): +def list_items( + lib: Library, query: list[str], album: bool, fmt: str = "" +) -> None: """Print out items in lib matching query. If album, then search for albums instead of single items. """ diff --git a/beets/ui/commands/modify.py b/beets/ui/commands/modify.py index 7b3b18d345..f2e1f0cea1 100644 --- a/beets/ui/commands/modify.py +++ b/beets/ui/commands/modify.py @@ -51,7 +51,17 @@ def _check_modify_operations( ) -def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): +def modify_items( + lib: Library, + mods: dict[str, ModifyOperation], + dels: list[str], + query: list[str], + write: bool, + move: bool, + album: bool, + confirm: bool, + inherit: bool, +) -> None: """Modifies matching items according to user-specified assignments and deletions. @@ -110,7 +120,9 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): obj.try_sync(write, move, inherit) -def print_and_modify(obj, mods, dels): +def print_and_modify( + obj: LibModel, mods: dict[str, list[str]], dels: list[str] +) -> bool: """Print the modifications to an item and return a bool indicating whether any changes were made. @@ -126,7 +138,9 @@ def print_and_modify(obj, mods, dels): return ui.show_model_changes(obj) -def modify_parse_args(args, is_album: bool): +def modify_parse_args( + args: list[str], is_album: bool +) -> tuple[list[str], dict[str, ModifyOperation], list[str]]: """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. diff --git a/beets/ui/commands/move.py b/beets/ui/commands/move.py index e88e312219..7b61f54602 100644 --- a/beets/ui/commands/move.py +++ b/beets/ui/commands/move.py @@ -15,14 +15,14 @@ if TYPE_CHECKING: import optparse - from beets.library import Library + from beets.library import Album, Item, Library from beets.util import PathLike # Global logger. log = logging.getLogger("beets") -def show_path_changes(path_changes): +def show_path_changes(path_changes: list[tuple[bytes, bytes]]) -> 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. @@ -64,15 +64,15 @@ def show_path_changes(path_changes): def move_items( - lib, + lib: Library, dest_path: PathLike, - query, - copy, - album, - pretend, - confirm=False, - export=False, -): + query: list[str], + copy: bool, + album: bool, + pretend: bool, + confirm: bool = False, + export: bool = False, +) -> None: """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. @@ -83,10 +83,10 @@ def move_items( num_objs = len(objs) # Filter out files that don't need to be moved. - def isitemmoved(item): + def isitemmoved(item: Item) -> bool: return item.path != item.destination(basedir=dest) - def isalbummoved(album): + def isalbummoved(album: Album) -> bool: return any(isitemmoved(i) for i in album.items()) objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)] diff --git a/beets/ui/commands/remove.py b/beets/ui/commands/remove.py index c9c3509045..120a4a6919 100644 --- a/beets/ui/commands/remove.py +++ b/beets/ui/commands/remove.py @@ -11,10 +11,12 @@ if TYPE_CHECKING: import optparse - from beets.library import Library + from beets.library import Album, Item, Library -def remove_items(lib, query, album, delete, force): +def remove_items( + lib: Library, query: list[str], album: bool, delete: bool, force: bool +) -> None: """Remove items matching query from lib. If album, then match and remove whole albums. If delete, also remove files from disk. """ @@ -48,10 +50,10 @@ def remove_items(lib, query, album, delete, force): ) # Helpers for printing affected items - def fmt_track(t): + def fmt_track(t: Item): ui.print_(format(t, fmt)) - def fmt_album(a): + def fmt_album(a: Album) -> None: ui.print_() for i in a.items(): fmt_track(i) diff --git a/beets/ui/commands/stats.py b/beets/ui/commands/stats.py index c8438c63b2..7065f101d8 100644 --- a/beets/ui/commands/stats.py +++ b/beets/ui/commands/stats.py @@ -19,7 +19,7 @@ log = logging.getLogger("beets") -def show_stats(lib, query, exact): +def show_stats(lib: Library, query: list[str], exact: bool) -> None: """Shows some statistics about the matched items.""" items = lib.items(query) diff --git a/beets/ui/commands/update.py b/beets/ui/commands/update.py index ebbe292364..ab8233d3bf 100644 --- a/beets/ui/commands/update.py +++ b/beets/ui/commands/update.py @@ -21,7 +21,15 @@ log = logging.getLogger("beets") -def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): +def update_items( + lib: Library, + query: list[str], + album: bool, + move: bool, + pretend: bool, + fields: list[str], + exclude_fields: list[str] | None = None, +) -> 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 diff --git a/beets/ui/commands/utils.py b/beets/ui/commands/utils.py index 1af308c9de..331512dd38 100644 --- a/beets/ui/commands/utils.py +++ b/beets/ui/commands/utils.py @@ -1,9 +1,18 @@ """Utility functions for beets UI commands.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from beets.exceptions import UserError +if TYPE_CHECKING: + from beets.library import Album, Item, Library + -def do_query(lib, query, album, also_items=True): +def do_query( + lib: Library, query: list[str], album: bool, also_items: bool = True +) -> tuple[list[Item], list[Album]]: """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 diff --git a/beets/ui/commands/version.py b/beets/ui/commands/version.py index a93c373a44..d53a221b40 100644 --- a/beets/ui/commands/version.py +++ b/beets/ui/commands/version.py @@ -6,7 +6,7 @@ from beets import plugins, ui -def show_version(*args): +def show_version(*args) -> None: ui.print_(f"beets version {beets.__version__}") ui.print_(f"Python version {python_version()}") # Show plugins. diff --git a/beets/ui/commands/write.py b/beets/ui/commands/write.py index c805c60ce4..c6f6fd87a6 100644 --- a/beets/ui/commands/write.py +++ b/beets/ui/commands/write.py @@ -20,7 +20,9 @@ log = logging.getLogger("beets") -def write_items(lib, query, pretend, force): +def write_items( + lib: Library, query: list[str], pretend: bool, force: bool +) -> None: """Write tag information from the database to the respective files in the filesystem. """ diff --git a/beets/util/config.py b/beets/util/config.py index 84346c5e2e..3cde8a2893 100644 --- a/beets/util/config.py +++ b/beets/util/config.py @@ -76,5 +76,5 @@ def sanitize_pairs( class UnknownPairError(Exception): - def __init__(self, k, v): + def __init__(self, k: str, v: str) -> None: super().__init__(f"setting {k}={v} is not recognized") diff --git a/beets/util/extension.py b/beets/util/extension.py index 7b87a98c93..77139bda7b 100644 --- a/beets/util/extension.py +++ b/beets/util/extension.py @@ -72,7 +72,9 @@ } -def fix_extension(path_bytes: PathBytes, logger: Logger | None = None): +def fix_extension( + path_bytes: PathBytes, logger: Logger | None = None +) -> bytes | Path: """Return the `path` after adding an appropriate extension if needed. If the file already has an extension, return as-is. diff --git a/beets/util/m3u.py b/beets/util/m3u.py index 0e154f0a4d..a417cae704 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -12,7 +12,7 @@ class EmptyPlaylistError(Exception): class M3UFile: """Reads and writes m3u or m3u8 playlist files.""" - def __init__(self, path): + def __init__(self, path: bytes) -> None: """``path`` is the absolute path to the playlist file. The playlist file type, m3u or m3u8 is determined by 1) the ending @@ -24,7 +24,7 @@ def __init__(self, path): self.extm3u = False self.media_list = [] - def load(self): + def load(self) -> None: """Reads the m3u file from disk and sets the object's attributes.""" pl_normpath = normpath(self.path) try: @@ -44,7 +44,9 @@ def load(self): if not self.media_list: raise EmptyPlaylistError - def set_contents(self, media_list, extm3u=True): + def set_contents( + self, media_list: list[bytes], extm3u: bool = True + ) -> None: """Sets self.media_list to a list of media file paths. Also sets additional flags, changing the final m3u-file's format. @@ -58,7 +60,7 @@ def set_contents(self, media_list, extm3u=True): self.media_list = media_list self.extm3u = extm3u - def write(self): + def write(self) -> None: """Writes the m3u file to disk. Handles the creation of potential parent directories. diff --git a/beets/util/pipeline.py b/beets/util/pipeline.py index a7ace7e5f2..5d270829b7 100644 --- a/beets/util/pipeline.py +++ b/beets/util/pipeline.py @@ -34,6 +34,7 @@ Iterable, Iterator, Sequence, + Sized, ) from types import TracebackType @@ -62,7 +63,7 @@ def _invalidate_queue( required (because it's not reentrant!). """ - def _qsize(len=len): # noqa: A002 + def _qsize(len: Callable[[Sized], int] = len) -> int: # noqa: A002 return 1 def _put(item: Any) -> None: @@ -181,7 +182,7 @@ def stage( """Decorate a function to become a simple stage. >>> @stage - ... def add(n, i): + ... def add(n: int, i: int): ... return i + n >>> pipe = Pipeline([ ... iter([1, 2, 3]), @@ -207,7 +208,7 @@ def mutator_stage( become a simple stage. >>> @mutator_stage - ... def setkey(key, item): + ... def setkey(key: str, item: Item): ... item[key] = True >>> pipe = Pipeline([ ... iter([{'x': False}, {'a': False}]), diff --git a/beets/util/units.py b/beets/util/units.py index f5fcb743b3..c7d7919711 100644 --- a/beets/util/units.py +++ b/beets/util/units.py @@ -14,7 +14,7 @@ def raw_seconds_short(string: str) -> float: return float(minutes * 60 + seconds) -def human_seconds_short(interval): +def human_seconds_short(interval: float) -> str: """Formats a number of seconds as a short human-readable M:SS string. """ @@ -22,7 +22,7 @@ def human_seconds_short(interval): return f"{interval // 60}:{interval % 60:02d}" -def human_bytes(size): +def human_bytes(size: float) -> str: """Formats size, a number of bytes, in a human-readable way.""" powers = ["", "K", "M", "G", "T", "P", "E", "Z", "Y", "H"] unit = "B" @@ -34,7 +34,7 @@ def human_bytes(size): return "big" -def human_seconds(interval): +def human_seconds(interval: float) -> str: """Formats interval, a number of seconds, as a human-readable time interval using English words. """ From 0fdce0bdc63ef765d8068ec1a7a70cc055aafea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 17 Aug 2026 21:18:55 +0100 Subject: [PATCH 2/4] typing: fix core annotations --- beets/autotag/distance.py | 12 ++++++- beets/events.py | 4 ++- beets/library/models.py | 9 ++++-- beets/test/_common.py | 2 +- beets/test/fixtures.py | 3 +- beets/test/helper.py | 3 ++ beets/ui/__init__.py | 56 +++++++++++++++++++-------------- beets/ui/commands/completion.py | 19 ++++++----- beets/ui/commands/help.py | 1 + beets/ui/commands/list.py | 9 ++++-- beets/ui/commands/move.py | 24 ++++++++++---- beets/ui/commands/remove.py | 19 ++++++----- beets/ui/commands/update.py | 14 ++++++--- beets/ui/commands/utils.py | 2 +- beets/util/extension.py | 6 ++-- beets/util/m3u.py | 2 ++ test/test_importer.py | 6 ++-- 17 files changed, 122 insertions(+), 69 deletions(-) diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index 52a51c52f9..8d401fb257 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -214,15 +214,25 @@ def __eq__(self, other: object) -> bool: # Behave like a float. def __lt__(self, other: object) -> bool: - return self.distance < other + return isinstance(other, (float, Distance)) and self.distance < other def __float__(self) -> float: return self.distance def __sub__(self, other: object) -> float: + if not isinstance(other, (float, Distance)): + raise TypeError( + "unsupported operand type(s) for -: " + f"'Distance' and {type(other).__name__!r}" + ) return self.distance - other def __rsub__(self, other: object) -> float: + if not isinstance(other, (float, Distance)): + raise TypeError( + "unsupported operand type(s) for -: " + f"'Distance' and {type(other).__name__!r}" + ) return other - self.distance def __str__(self) -> str: diff --git a/beets/events.py b/beets/events.py index c0e177cd5a..bf9d0b4daf 100644 --- a/beets/events.py +++ b/beets/events.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal, TypedDict +from itertools import chain +from typing import TYPE_CHECKING, Any, Literal, TypedDict, get_args if TYPE_CHECKING: from collections.abc import Mapping @@ -68,6 +69,7 @@ | NoArgsEventType | AfterConvertEventType ) +ALL_EVENTS = list(chain.from_iterable(get_args(e) for e in get_args(EventType))) class AfterWriteEventArgs(TypedDict): diff --git a/beets/library/models.py b/beets/library/models.py index d7d405feab..179cd083c7 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -91,10 +91,13 @@ def store(self, fields: Iterable[str] | None = None) -> None: super().store(fields) plugins.send("database_change", lib=self.db, model=self) - def remove(self) -> None: + def _remove(self) -> None: super().remove() plugins.send("database_change", lib=self.db, model=self) + def remove(self, delete: bool = False) -> None: + raise NotImplementedError + def add(self, lib: Library | None = None) -> None: # super().add() calls self.store(), which sends `database_change`, # so don't do it here @@ -386,7 +389,7 @@ def remove(self, delete: bool = False, with_items: bool = True) -> None: Set with_items to False to avoid removing the album's items. """ - super().remove() + super()._remove() # Send a 'album_removed' signal to plugins plugins.send("album_removed", album=self) @@ -1121,7 +1124,7 @@ def remove(self, delete: bool = False, with_album: bool = True) -> None: If `with_album`, then the item's album (if any) is removed if the item was the last in the album. """ - super().remove() + super()._remove() # Remove the album if it is empty. if with_album: diff --git a/beets/test/_common.py b/beets/test/_common.py index 23091e6f3c..b147e1063b 100644 --- a/beets/test/_common.py +++ b/beets/test/_common.py @@ -81,7 +81,7 @@ def item(lib: Library | None = None, **kwargs) -> Item: # Dummy import session. def import_session( - lib: Library | None = None, + lib: Library, loghandler: logging.Handler | None = None, paths: list[bytes] = [], query: list[str] = [], diff --git a/beets/test/fixtures.py b/beets/test/fixtures.py index 28a650504e..7acad0c510 100644 --- a/beets/test/fixtures.py +++ b/beets/test/fixtures.py @@ -47,9 +47,10 @@ class DummyIMBackend(IMBackend): The version is sufficiently recent to support image comparison. """ + _version = (7, 0, 0) + def __init__(self) -> None: """Init a dummy backend class for mocked ImageMagick tests.""" - self.version = (7, 0, 0) self.legacy = False self.convert_cmd = ["magick"] self.identify_cmd = ["magick", "identify"] diff --git a/beets/test/helper.py b/beets/test/helper.py index 4d7f614aa9..5dde586354 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -680,6 +680,9 @@ def choose_match( class TerminalImportSessionFixture(TerminalImportSession): + _choices: list[importer.Action | int] + _duplicate_actions: list[importer.DuplicateAction] + def __init__(self, *args, **kwargs) -> None: self.io = kwargs.pop("io") super().__init__(*args, **kwargs) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index cee8eeb9a9..1bfd9dd9ea 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -14,7 +14,7 @@ import textwrap import traceback from functools import cache -from typing import TYPE_CHECKING, Any, TextIO +from typing import TYPE_CHECKING, Any, Literal, TextIO import confuse @@ -265,24 +265,25 @@ def input_options( # The default is just the first option if unspecified. if require: - default = None + default_choice = None elif default is None: - if numrange: - default = numrange[0] - else: - default = display_letters[0].lower() + default_choice = numrange[0] if numrange else display_letters[0].lower() + else: + default_choice = default # 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) + if isinstance(default_choice, int): + default_name = str(default_choice) 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))) + prompt_part_lengths.append( + len(tmpl) - 2 + len(str(default_choice)) + ) else: prompt_parts.append("# selection") prompt_part_lengths.append(len(prompt_parts[-1])) @@ -329,20 +330,20 @@ def input_options( resp = resp.strip().lower() # Try default option. - if default is not None and not resp: - resp = default + if default_choice is not None and not resp: + resp = default_choice # type: ignore[assignment] # Try an integer input if available. if numrange: try: - resp = int(resp) + int_resp = int(resp) except ValueError: pass else: low, high = numrange - if low <= resp <= high: - return resp - resp = None + if low <= int_resp <= high: + return int_resp + resp = None # type: ignore[assignment] # Try a normal letter input. if resp: @@ -464,6 +465,8 @@ class CommonOptionsParser(optparse.OptionParser): Each method is fully documented in the related method. """ + _album_flags: set[str] | Literal[False] + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._album_flags = False @@ -500,21 +503,21 @@ def _set_format( arguments. """ if store_true: - setattr(parser.values, option.dest, True) + setattr(parser.values, option.dest, True) # type: ignore[arg-type] # Use the explicitly specified format, or the string from the option. value = fmt or value or "" - parser.values.format = value + parser.values.format = value # type: ignore[union-attr] if target: config[target._format_config_key].set(value) else: if self._album_flags: - if parser.values.album: + if parser.values.album: # type: ignore[union-attr] target = library.Album else: # the option is either missing either not parsed yet - if self._album_flags & set(parser.rargs): + if self._album_flags & set(parser.rargs or []): target = library.Album else: target = library.Item @@ -546,7 +549,7 @@ def add_path_option(self, flags: Sequence[str] = ("-p", "--path")) -> None: def add_format_option( self, flags: Sequence[str] = ("-f", "--format"), - target: str | None = None, + target: type[LibModel] | Literal["item", "album"] | None = None, ) -> None: """Add -f/--format option to print some LibModel instances with a custom format. @@ -564,9 +567,11 @@ def add_format_option( """ kwargs = {} if target: - if isinstance(target, str): - target = {"item": library.Item, "album": library.Album}[target] - kwargs["target"] = target + kwargs["target"] = ( + {"item": library.Item, "album": library.Album}[target] + if isinstance(target, str) + else target + ) opt = optparse.Option( *flags, @@ -599,6 +604,7 @@ class Subcommand: """ func: Callable[[library.Library, optparse.Values, list[str]], Any] + _root_parser: optparse.OptionParser | None def __init__( self, @@ -644,6 +650,8 @@ class SubcommandsOptionParser(CommonOptionsParser): arguments. """ + subcommands: list[Subcommand] + def __init__(self, *args, **kwargs) -> None: """Create a new subcommand-aware option parser. All of the options to OptionParser.__init__ are supported in addition @@ -736,7 +744,7 @@ def _subcommand_for_name(self, name: str) -> Subcommand | None: return None def parse_global_options( - self, args: list[str] + self, args: list[str] | None ) -> tuple[optparse.Values, list[str]]: """Parse options up to the subcommand argument. Returns a tuple of the options object and the remaining arguments. diff --git a/beets/ui/commands/completion.py b/beets/ui/commands/completion.py index db739deecd..fb519df0ea 100644 --- a/beets/ui/commands/completion.py +++ b/beets/ui/commands/completion.py @@ -57,10 +57,10 @@ def completion_script(commands: Sequence[ui.Subcommand]) -> Iterator[str]: base_script = os.path.join( os.path.dirname(__file__), "./completion_base.sh" ) - with open(base_script) as base_script: - yield base_script.read() + with open(base_script) as f: + yield f.read() - options = {} + options: dict[str, dict[str, list[str]]] = {} aliases = {} command_names = [] @@ -102,8 +102,8 @@ def completion_script(commands: Sequence[ui.Subcommand]) -> Iterator[str]: # 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" + for alias, _cmd in aliases.items(): + yield f" local alias__{alias.replace('-', '_')}={_cmd}\n" yield "\n" # Fields @@ -111,13 +111,12 @@ def completion_script(commands: Sequence[ui.Subcommand]) -> Iterator[str]: yield f" fields={' '.join(fields)!r}\n" # Command options - for cmd, opts in options.items(): - for option_type, option_list in opts.items(): + 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" + f" local {option_type}__{_cmd.replace('-', '_')}" + f"='{' '.join(option_list)}'\n" ) yield " _beet_dispatch\n" diff --git a/beets/ui/commands/help.py b/beets/ui/commands/help.py index fecc32a9d3..c172968bf5 100644 --- a/beets/ui/commands/help.py +++ b/beets/ui/commands/help.py @@ -24,6 +24,7 @@ def __init__(self) -> None: def func( self, lib: Library, opts: optparse.Values, args: list[str] ) -> None: + assert isinstance(self.root_parser, ui.SubcommandsOptionParser) if args: cmdname = args[0] helpcommand = self.root_parser._subcommand_for_name(cmdname) diff --git a/beets/ui/commands/list.py b/beets/ui/commands/list.py index 0c8c311aaa..d467e3a4e4 100644 --- a/beets/ui/commands/list.py +++ b/beets/ui/commands/list.py @@ -19,8 +19,8 @@ def list_items( albums instead of single items. """ if album: - for album in lib.albums(query): - ui.print_(format(album, fmt)) + for _album in lib.albums(query): + ui.print_(format(_album, fmt)) else: for item in lib.items(query): ui.print_(format(item, fmt)) @@ -31,6 +31,9 @@ def list_func(lib: Library, opts: optparse.Values, args: list[str]) -> None: 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.set_usage( + list_cmd.parser.get_usage().rstrip() + + "\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/move.py b/beets/ui/commands/move.py index 7b61f54602..b72357949f 100644 --- a/beets/ui/commands/move.py +++ b/beets/ui/commands/move.py @@ -5,6 +5,8 @@ import os from typing import TYPE_CHECKING +from typing_extensions import TypeIs + from beets import logging, ui from beets.exceptions import UserError from beets.util import MoveOperation, displayable_path, normpath, syspath @@ -37,11 +39,11 @@ def show_path_changes(path_changes: list[tuple[bytes, bytes]]) -> None: Source -> Destination """ - sources, destinations = zip(*path_changes) + sources_bytes, destinations_bytes = zip(*path_changes) # Ensure unicode output - sources = list(map(displayable_path, sources)) - destinations = list(map(displayable_path, destinations)) + sources = list(map(displayable_path, sources_bytes)) + destinations = list(map(displayable_path, destinations_bytes)) # Calculate widths for terminal split col_width = (ui.term_width() - len(" -> ")) // 2 @@ -63,6 +65,12 @@ def show_path_changes(path_changes: list[tuple[bytes, bytes]]) -> None: ui.print_(f"{color_source} {' ' * pad} -> {color_dest}") +def is_album_selection( + objects: list[Item] | list[Album], album: bool +) -> TypeIs[list[Album]]: + return album + + def move_items( lib: Library, dest_path: PathLike, @@ -77,7 +85,7 @@ def move_items( 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 + dest = os.fsencode(dest_path) if dest_path else None items, albums = do_query(lib, query, album, False) objs = albums if album else items num_objs = len(objs) @@ -89,7 +97,11 @@ def isitemmoved(item: Item) -> bool: def isalbummoved(album: Album) -> bool: return any(isitemmoved(i) for i in album.items()) - objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)] + if is_album_selection(objs, album): + objs = list(filter(isalbummoved, objs)) + else: + objs = list(filter(isitemmoved, objs)) + num_unmoved = num_objs - len(objs) # Report unmoved files that match the query. unmoved_msg = "" @@ -112,7 +124,7 @@ def isalbummoved(album: Album) -> bool: return if pretend: - if album: + if is_album_selection(objs, album): show_path_changes( [ (item.path, item.destination(basedir=dest)) diff --git a/beets/ui/commands/remove.py b/beets/ui/commands/remove.py index 120a4a6919..35574d76b0 100644 --- a/beets/ui/commands/remove.py +++ b/beets/ui/commands/remove.py @@ -2,16 +2,18 @@ from __future__ import annotations +from functools import singledispatch from typing import TYPE_CHECKING from beets import ui +from beets.library import Album, Item from .utils import do_query if TYPE_CHECKING: import optparse - from beets.library import Album, Item, Library + from beets.library import LibModel, Library def remove_items( @@ -49,16 +51,19 @@ def remove_items( " from the library?" ) - # Helpers for printing affected items - def fmt_track(t: Item): + @singledispatch + def fmt_obj(obj: LibModel) -> None: + raise NotImplementedError + + @fmt_obj.register + def _item(t: Item) -> None: ui.print_(format(t, fmt)) - def fmt_album(a: Album) -> None: + @fmt_obj.register + def _album(a: Album) -> None: ui.print_() for i in a.items(): - fmt_track(i) - - fmt_obj = fmt_album if album else fmt_track + fmt_obj(i) # Show all the items. for o in objs: diff --git a/beets/ui/commands/update.py b/beets/ui/commands/update.py index ab8233d3bf..b929232e84 100644 --- a/beets/ui/commands/update.py +++ b/beets/ui/commands/update.py @@ -24,7 +24,7 @@ def update_items( lib: Library, query: list[str], - album: bool, + is_album: bool, move: bool, pretend: bool, fields: list[str], @@ -38,7 +38,7 @@ def update_items( fields will be. """ with lib.transaction(): - items, _ = do_query(lib, query, album) + items, _ = do_query(lib, query, is_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 @@ -94,7 +94,11 @@ def update_items( # 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 and (old_item := lib.get_item(item.id)): + if ( + not item.albumartist + and item.id + and (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") @@ -130,7 +134,9 @@ def update_items( if not album: # Empty albums have already been removed. log.debug("emptied album {}", album_id) continue - first_item = album.items().get() + + if not (first_item := album.items().get()): + continue # Update album structure to reflect an item in it. for key in library.Album.item_keys: diff --git a/beets/ui/commands/utils.py b/beets/ui/commands/utils.py index 331512dd38..8879d553dc 100644 --- a/beets/ui/commands/utils.py +++ b/beets/ui/commands/utils.py @@ -19,9 +19,9 @@ def do_query( a UserError if no items match. also_items controls whether, when fetching albums, the associated items should be fetched also. """ + items: list[Item] = [] if album: albums = list(lib.albums(query)) - items = [] if also_items: for al in albums: items += al.items() diff --git a/beets/util/extension.py b/beets/util/extension.py index 77139bda7b..d3b051448b 100644 --- a/beets/util/extension.py +++ b/beets/util/extension.py @@ -72,9 +72,7 @@ } -def fix_extension( - path_bytes: PathBytes, logger: Logger | None = None -) -> bytes | Path: +def fix_extension(path_bytes: PathBytes, logger: Logger | None = None) -> bytes: """Return the `path` after adding an appropriate extension if needed. If the file already has an extension, return as-is. @@ -142,7 +140,7 @@ def fix_extension( else: if logger: logger.info("Import file with matching format to original target") - return new_path + return os.fsencode(new_path) def remux_mpeglayer3_wav(path: AnyPath) -> AnyPath | None: diff --git a/beets/util/m3u.py b/beets/util/m3u.py index a417cae704..f280b710b0 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -12,6 +12,8 @@ class EmptyPlaylistError(Exception): class M3UFile: """Reads and writes m3u or m3u8 playlist files.""" + media_list: list[bytes] + def __init__(self, path: bytes) -> None: """``path`` is the absolute path to the playlist file. diff --git a/test/test_importer.py b/test/test_importer.py index 7723535795..19f30b7afa 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -1621,18 +1621,18 @@ def test_upgrade_skips_lower_quality_new_copy(self): assert self.old_item.filepath.exists() -class TagLogTest(unittest.TestCase): +class TagLogTest(TestHelper): def test_tag_log_line(self): sio = StringIO() handler = logging.StreamHandler(sio) - session = _common.import_session(loghandler=handler) + session = _common.import_session(self.lib, loghandler=handler) session.tag_log("status", "path") assert "status path" in sio.getvalue() def test_tag_log_unicode(self): sio = StringIO() handler = logging.StreamHandler(sio) - session = _common.import_session(loghandler=handler) + session = _common.import_session(self.lib, loghandler=handler) session.tag_log("status", "caf\xe9") # send unicode assert "status caf\xe9" in sio.getvalue() From 491eef8a8c13057bd759b8fbbb6b3da9d82b2672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 18 Aug 2026 19:46:35 +0100 Subject: [PATCH 3/4] Replace do_query with model-specific logic This allows to define a proper return type for input_select_objects. --- beets/ui/__init__.py | 8 +- beets/ui/commands/modify.py | 61 ++++++++------ beets/ui/commands/move.py | 137 ++++++++++++++++++-------------- beets/ui/commands/remove.py | 101 ++++++++++++----------- beets/ui/commands/update.py | 16 ++-- beets/ui/commands/utils.py | 38 --------- beets/ui/commands/write.py | 4 +- beetsplug/edit.py | 4 +- test/ui/commands/test_move.py | 52 ++++++------ test/ui/commands/test_remove.py | 14 ++-- test/ui/commands/test_update.py | 11 +-- test/ui/commands/test_utils.py | 54 ------------- 12 files changed, 217 insertions(+), 283 deletions(-) delete mode 100644 beets/ui/commands/utils.py delete mode 100644 test/ui/commands/test_utils.py diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 1bfd9dd9ea..e2504570da 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -31,7 +31,7 @@ from collections.abc import Callable, Iterable, Sequence from pathlib import Path - from beets.library import LibModel + from beets.library import AlbumOrItem, LibModel from beets.util.color import ColorName @@ -369,10 +369,10 @@ def input_yn(prompt: str, require: bool = False) -> bool: def input_select_objects( prompt: str, - objs: Sequence[LibModel], - rep: Callable[[LibModel], Any], + objs: Sequence[AlbumOrItem], + rep: Callable[[AlbumOrItem], Any], prompt_all: str | None = None, -) -> Any: +) -> Sequence[AlbumOrItem]: """Prompt to user to choose all, none, or some of the given objects. Return the list of selected objects. diff --git a/beets/ui/commands/modify.py b/beets/ui/commands/modify.py index f2e1f0cea1..d5907755fe 100644 --- a/beets/ui/commands/modify.py +++ b/beets/ui/commands/modify.py @@ -4,17 +4,17 @@ from typing import TYPE_CHECKING, NamedTuple -from beets import library, ui +from beets import ui from beets.dbcore import types from beets.exceptions import UserError +from beets.library import Album, Item from beets.util.deprecation import maybe_replace_legacy_field -from .utils import do_query - if TYPE_CHECKING: import optparse + from collections.abc import Sequence - from beets.library import LibModel, Library + from beets.library import AlbumOrItem, LibModel, Library class ModifyOperation(NamedTuple): @@ -51,34 +51,22 @@ def _check_modify_operations( ) -def modify_items( +def modify_objects( + model_cls: type[AlbumOrItem], + objs: Sequence[AlbumOrItem], lib: Library, mods: dict[str, ModifyOperation], dels: list[str], - query: list[str], write: bool, move: bool, - album: bool, confirm: bool, inherit: bool, ) -> None: - """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 _check_modify_operations(model_cls, mods) - - # 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. - ui.print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.") + ui.print_(f"Modifying {len(objs)} {model_cls.__name__.lower()}s.") changed = [] for obj in objs: obj_mods = { @@ -98,6 +86,7 @@ def modify_items( return # Confirm action. + selected_changes: Sequence[AlbumOrItem] if confirm: if write and move: extra = ", move and write tags" @@ -108,18 +97,40 @@ def modify_items( else: extra = "" - changed = ui.input_select_objects( + selected_changes = ui.input_select_objects( f"Really modify{extra}", changed, lambda o: print_and_modify(o, mods, dels), ) + else: + selected_changes = changed # Apply changes to database and files with lib.transaction(): - for obj in changed: + for obj in selected_changes: obj.try_sync(write, move, inherit) +def modify_items(lib: Library, query: list[str], *args, **kwargs) -> None: + """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. + """ + modify_objects(Item, list(lib.items(query)), lib, *args, **kwargs) + + +def modify_albums(lib: Library, query: list[str], *args, **kwargs) -> None: + """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. + """ + modify_objects(Album, list(lib.albums(query)), lib, *args, **kwargs) + + def print_and_modify( obj: LibModel, mods: dict[str, list[str]], dels: list[str] ) -> bool: @@ -170,14 +181,14 @@ def modify_func(lib: Library, opts: optparse.Values, args: list[str]) -> None: query, mods, dels = modify_parse_args(args, is_album=opts.album) if not mods and not dels: raise UserError("no modifications specified") - modify_items( + method = modify_albums if opts.album else modify_items + method( lib, + query, mods, dels, - query, ui.should_write(opts.write), ui.should_move(opts.move), - opts.album, not opts.yes, opts.inherit, ) diff --git a/beets/ui/commands/move.py b/beets/ui/commands/move.py index b72357949f..0d16a81e56 100644 --- a/beets/ui/commands/move.py +++ b/beets/ui/commands/move.py @@ -3,22 +3,18 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING - -from typing_extensions import TypeIs +from typing import TYPE_CHECKING, Literal from beets import logging, ui from beets.exceptions import UserError from beets.util import MoveOperation, displayable_path, normpath, syspath from beets.util.diff import colordiff -from .utils import do_query - if TYPE_CHECKING: import optparse + from collections.abc import Callable, Iterable, Sequence - from beets.library import Album, Item, Library - from beets.util import PathLike + from beets.library import Album, AlbumOrItem, Item, Library # Global logger. log = logging.getLogger("beets") @@ -65,43 +61,18 @@ def show_path_changes(path_changes: list[tuple[bytes, bytes]]) -> None: ui.print_(f"{color_source} {' ' * pad} -> {color_dest}") -def is_album_selection( - objects: list[Item] | list[Album], album: bool -) -> TypeIs[list[Album]]: - return album - - -def move_items( - lib: Library, - dest_path: PathLike, - query: list[str], +def move_objects( + objs: list[AlbumOrItem], + entity: Literal["album", "item"], + dest: bytes | None, + get_paths: Callable[[Iterable[AlbumOrItem]], list[tuple[bytes, bytes]]], + *, copy: bool, - album: bool, pretend: bool, confirm: bool = False, export: bool = False, ) -> None: - """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 None - 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: Item) -> bool: - return item.path != item.destination(basedir=dest) - - def isalbummoved(album: Album) -> bool: - return any(isitemmoved(i) for i in album.items()) - - if is_album_selection(objs, album): - objs = list(filter(isalbummoved, objs)) - else: - objs = list(filter(isitemmoved, objs)) - num_unmoved = num_objs - len(objs) # Report unmoved files that match the query. unmoved_msg = "" @@ -111,7 +82,6 @@ def isalbummoved(album: Album) -> bool: 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, @@ -124,29 +94,21 @@ def isalbummoved(album: Album) -> bool: return if pretend: - if is_album_selection(objs, 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] - ) + show_path_changes(get_paths(objs)) else: + selected_objs: Sequence[AlbumOrItem] if confirm: - objs = ui.input_select_objects( + selected_objs = ui.input_select_objects( f"Really {act}", objs, lambda o: show_path_changes( [(o.path, o.destination(basedir=dest))] ), ) + else: + selected_objs = objs - for obj in objs: + for obj in selected_objs: log.debug("moving: {.filepath}", obj) if export: @@ -162,6 +124,60 @@ def isalbummoved(album: Album) -> bool: obj.move(operation=MoveOperation.MOVE, basedir=dest) +def isitemmoved(dest: bytes | None, item: Item) -> bool: + """Filter out files that don't need to be moved.""" + return item.path != item.destination(basedir=dest) + + +def move_items( + lib: Library, query: list[str], dest: bytes | None, *args, **kwargs +) -> None: + """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. + """ + + def get_paths(objs: Iterable[Item]) -> list[tuple[bytes, bytes]]: + return [(obj.path, obj.destination(basedir=dest)) for obj in objs] + + move_objects( + [i for i in lib.items(query) if isitemmoved(dest, i)], + "item", + dest, + get_paths, + *args, + **kwargs, + ) + + +def move_albums( + lib: Library, query: list[str], dest: bytes | None, *args, **kwargs +) -> None: + """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. + """ + + def isalbummoved(dest: bytes | None, album: Album) -> bool: + return any(isitemmoved(dest, i) for i in album.items()) + + def get_paths(objs: Iterable[Album]) -> list[tuple[bytes, bytes]]: + return [ + (item.path, item.destination(basedir=dest)) + for obj in objs + for item in obj.items() + ] + + move_objects( + [i for i in lib.albums(query) if isalbummoved(dest, i)], + "album", + dest, + get_paths, + *args, + **kwargs, + ) + + def move_func(lib: Library, opts: optparse.Values, args: list[str]) -> None: dest = opts.dest if dest is not None: @@ -169,15 +185,16 @@ def move_func(lib: Library, opts: optparse.Values, args: list[str]) -> None: if not os.path.isdir(syspath(dest)): raise UserError(f"no such directory: {displayable_path(dest)}") - move_items( + dest = os.fsencode(opts.dest) if opts.dest else None + method = move_albums if opts.album else move_items + method( lib, - dest, args, - opts.copy, - opts.album, - opts.pretend, - opts.timid, - opts.export, + dest, + copy=opts.copy, + pretend=opts.pretend, + confirm=opts.timid, + export=opts.export, ) diff --git a/beets/ui/commands/remove.py b/beets/ui/commands/remove.py index 35574d76b0..57bd63f4c6 100644 --- a/beets/ui/commands/remove.py +++ b/beets/ui/commands/remove.py @@ -2,89 +2,94 @@ from __future__ import annotations -from functools import singledispatch +from functools import partial from typing import TYPE_CHECKING from beets import ui -from beets.library import Album, Item - -from .utils import do_query if TYPE_CHECKING: import optparse + from collections.abc import Callable, Sequence - from beets.library import LibModel, Library + from beets.library import Album, AlbumOrItem, Item, Library -def remove_items( - lib: Library, query: list[str], album: bool, delete: bool, force: bool +def remove_objects( + objs: Sequence[AlbumOrItem], + fmt_obj: Callable[[str, AlbumOrItem], None], + suffix: str, + lib: Library, + delete: bool, + force: bool, ) -> None: - """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 force: + selected_objs = objs + 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}" - ) + prompt_all = f"Really DELETE {len(objs)} file{suffix}" 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?" + f"Really remove {len(objs)} item{suffix} from the library?" ) - @singledispatch - def fmt_obj(obj: LibModel) -> None: - raise NotImplementedError - - @fmt_obj.register - def _item(t: Item) -> None: - ui.print_(format(t, fmt)) - - @fmt_obj.register - def _album(a: Album) -> None: - ui.print_() - for i in a.items(): - fmt_obj(i) + _fmt = partial(fmt_obj, fmt) # Show all the items. for o in objs: - fmt_obj(o) + _fmt(o) # Confirm with user. - objs = ui.input_select_objects( - prompt, objs, fmt_obj, prompt_all=prompt_all + selected_objs = ui.input_select_objects( + prompt, objs, _fmt, prompt_all=prompt_all ) - if not objs: + if not selected_objs: return # Remove (and possibly delete) items. with lib.transaction(): - for obj in objs: + for obj in selected_objs: obj.remove(delete) +def fmt_item(fmt: str, t: Item) -> None: + ui.print_(format(t, fmt)) + + +def fmt_album(fmt: str, a: Album) -> None: + ui.print_() + for i in a.items(): + fmt_item(fmt, i) + + +def remove_items(lib: Library, query: list[str], *args, **kwargs) -> None: + """Remove items matching query from lib.""" + items: Sequence[Item] = list(lib.items(query)) + suffix = "s" if len(items) > 1 else "" + + remove_objects(items, fmt_item, suffix, lib, *args, **kwargs) + + +def remove_albums(lib: Library, query: list[str], *args, **kwargs) -> None: + """Remove albums matching query from lib.""" + albums = list(lib.albums(query)) + items = [i for a in albums for i in a.items()] + suffix = "s" if len(items) > 1 else "" + album_str = f" in {len(albums)} album{'s' if len(albums) > 1 else ''}" + + remove_objects( + albums, fmt_album, f"{suffix}{album_str}", lib, *args, **kwargs + ) + + def remove_func(lib: Library, opts: optparse.Values, args: list[str]) -> None: - remove_items(lib, args, opts.album, opts.delete, opts.force) + method = remove_albums if opts.album else remove_items + method(lib, args, opts.delete, opts.force) remove_cmd = ui.Subcommand( diff --git a/beets/ui/commands/update.py b/beets/ui/commands/update.py index b929232e84..fafb21cfd0 100644 --- a/beets/ui/commands/update.py +++ b/beets/ui/commands/update.py @@ -9,12 +9,11 @@ from beets.util import ancestry, syspath from beets.util.color import colorize -from .utils import do_query - if TYPE_CHECKING: import optparse + from collections.abc import Iterable - from beets.library import Library + from beets.library import Item, Library # Global logger. @@ -23,8 +22,7 @@ def update_items( lib: Library, - query: list[str], - is_album: bool, + items: Iterable[Item], move: bool, pretend: bool, fields: list[str], @@ -38,7 +36,6 @@ def update_items( fields will be. """ with lib.transaction(): - items, _ = do_query(lib, query, is_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 @@ -163,10 +160,13 @@ def update_func(lib: Library, opts: optparse.Values, args: list[str]) -> None: ui.print_(os.fsdecode(lib.directory)) if not ui.input_yn("Are you sure you want to continue (y/n)?", True): return + if opts.album: + items = [i for a in lib.albums(args) for i in a.items()] + else: + items = list(lib.items(args)) update_items( lib, - args, - opts.album, + items, ui.should_move(opts.move), opts.pretend, opts.fields, diff --git a/beets/ui/commands/utils.py b/beets/ui/commands/utils.py deleted file mode 100644 index 8879d553dc..0000000000 --- a/beets/ui/commands/utils.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Utility functions for beets UI commands.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from beets.exceptions import UserError - -if TYPE_CHECKING: - from beets.library import Album, Item, Library - - -def do_query( - lib: Library, query: list[str], album: bool, also_items: bool = True -) -> tuple[list[Item], list[Album]]: - """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. - """ - items: list[Item] = [] - if album: - albums = list(lib.albums(query)) - 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.") - if not album and not items: - raise UserError("No matching items found.") - - return items, albums diff --git a/beets/ui/commands/write.py b/beets/ui/commands/write.py index c6f6fd87a6..f07d8c5aac 100644 --- a/beets/ui/commands/write.py +++ b/beets/ui/commands/write.py @@ -8,8 +8,6 @@ from beets import library, logging, ui from beets.util import syspath -from .utils import do_query - if TYPE_CHECKING: import optparse @@ -26,7 +24,7 @@ def write_items( """Write tag information from the database to the respective files in the filesystem. """ - items, _ = do_query(lib, query, False, False) + items = lib.items(query) for item in items: # Item deleted? diff --git a/beetsplug/edit.py b/beetsplug/edit.py index e6ed9519c8..aea86779ba 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -17,7 +17,6 @@ from beets.exceptions import UserError from beets.importer import Action from beets.library import Album, Item -from beets.ui.commands.utils import do_query from beets.util import PromptChoice if TYPE_CHECKING: @@ -182,8 +181,7 @@ def _edit_command( ) -> None: """The CLI command function for the `beet edit` command.""" # Get the objects to edit. - items, albums = do_query(lib, args, opts.album, False) - objs = albums if opts.album else items + objs = (lib.albums if opts.album else lib.items)(args) if not objs: ui.print_("Nothing to edit.") return diff --git a/test/ui/commands/test_move.py b/test/ui/commands/test_move.py index fbbb69a38f..df397296c1 100644 --- a/test/ui/commands/test_move.py +++ b/test/ui/commands/test_move.py @@ -1,8 +1,9 @@ +import os import shutil from beets import library from beets.test.helper import BeetsTestCase -from beets.ui.commands.move import move_items +from beets.ui.commands.move import move_albums, move_items class MoveTest(BeetsTestCase): @@ -20,83 +21,86 @@ def setUp(self): # Alternate destination directory. self.otherdir = self.temp_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 _move_items(self, dest=None, query=(), **kwargs): + kwargs.setdefault("pretend", False) + kwargs.setdefault("copy", False) + move_items( + self.lib, query, os.fsencode(dest) if dest else None, **kwargs + ) + + def _move_albums(self, dest=None, query=(), **kwargs): + kwargs.setdefault("pretend", False) + kwargs.setdefault("copy", False) + move_albums( + self.lib, query, os.fsencode(dest) if dest else None, **kwargs + ) def test_move_item(self): - self._move() + self._move_items() 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._move_items(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._move_albums() 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._move_albums(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_item_custom_dir(self): - self._move(dest=self.otherdir) + self._move_items(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._move_albums(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_pretend_move_item(self): - self._move(dest=self.otherdir, pretend=True) + self._move_items(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._move_albums(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._move_items(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._move_albums(dest=self.otherdir, 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._move_items(dest=self.otherdir, pretend=True, export=True) self.i.load() assert self.i.filepath == self.initial_item_path assert not self.otherdir.exists() @@ -105,7 +109,7 @@ def test_move_missing_singleton_continues(self): self.i.load() old_path = self.i.filepath old_path.unlink() - self._move() + self._move_items() self.i.load() assert self.i.filepath == old_path @@ -121,7 +125,7 @@ def test_move_album_with_missing_track(self): i2.album_id = self.album.id i2.store() - self._move(album=True) + self._move_albums() self.i.load() i2.load() assert self.i.filepath == old_i_path diff --git a/test/ui/commands/test_remove.py b/test/ui/commands/test_remove.py index 33c52ac242..1c7551fd0e 100644 --- a/test/ui/commands/test_remove.py +++ b/test/ui/commands/test_remove.py @@ -1,6 +1,6 @@ from beets import library from beets.test.helper import BeetsTestCase, IOMixin -from beets.ui.commands.remove import remove_items +from beets.ui.commands.remove import remove_albums, remove_items from beets.util import MoveOperation @@ -15,26 +15,26 @@ def setUp(self): def test_remove_items_no_delete(self): self.io.addinput("y") - remove_items(self.lib, "", False, False, False) + remove_items(self.lib, "", 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) + remove_items(self.lib, "", 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) + remove_items(self.lib, "", 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) + remove_items(self.lib, "", True, True) items = self.lib.items() assert len(list(items)) == 0 assert not self.i.filepath.exists() @@ -46,7 +46,7 @@ def test_remove_items_select_with_delete(self): for s in ("s", "y", "n"): self.io.addinput(s) - remove_items(self.lib, "", False, True, False) + remove_items(self.lib, "", True, False) items = self.lib.items() assert len(list(items)) == 1 # There is probably no guarantee that the items are queried in any @@ -68,7 +68,7 @@ def test_remove_albums_select_with_delete(self): for s in ("s", "y", "n"): self.io.addinput(s) - remove_items(self.lib, "", True, True, False) + remove_albums(self.lib, "", True, False) items = self.lib.items() assert len(list(items)) == 2 # incl. the item from setUp() # See test_remove_items_select_with_delete() diff --git a/test/ui/commands/test_update.py b/test/ui/commands/test_update.py index 6a6681560e..43541e57db 100644 --- a/test/ui/commands/test_update.py +++ b/test/ui/commands/test_update.py @@ -32,13 +32,7 @@ def setUp(self): artfile.unlink() def _update( - self, - query=(), - album=False, - move=False, - reset_mtime=True, - fields=None, - exclude_fields=None, + self, move=False, reset_mtime=True, fields=None, exclude_fields=None ): self.io.addinput("y") if reset_mtime: @@ -46,8 +40,7 @@ def _update( self.i.store() update_items( self.lib, - query, - album, + list(self.lib.items()), move, False, fields=fields, diff --git a/test/ui/commands/test_utils.py b/test/ui/commands/test_utils.py deleted file mode 100644 index a6ef11f461..0000000000 --- a/test/ui/commands/test_utils.py +++ /dev/null @@ -1,54 +0,0 @@ -import shutil - -import pytest - -from beets import library -from beets.exceptions import UserError -from beets.test import _common -from beets.test.helper import BeetsTestCase -from beets.ui.commands.utils import do_query - - -class QueryTest(BeetsTestCase): - def add_item(self): - itempath = self.lib_path / "srcfile" - shutil.copy(_common.RSRC / "full.mp3", itempath) - item = library.Item.from_path(itempath) - self.lib.add(item) - return item - - def add_album(self, items): - return self.lib.add_album(items) - - def check_do_query( - self, num_items, num_albums, q=(), album=False, also_items=True - ): - 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(UserError): - do_query(self.lib, (), False) - - def test_query_empty_album(self): - with pytest.raises(UserError): - do_query(self.lib, (), True) - - def test_query_item(self): - self.add_item() - self.check_do_query(1, 0, album=False) - self.add_item() - self.check_do_query(2, 0, album=False) - - def test_query_album(self): - item = self.add_item() - self.add_album([item]) - self.check_do_query(1, 1, album=True) - self.check_do_query(0, 1, album=True, also_items=False) - - item = self.add_item() - item2 = self.add_item() - self.add_album([item, item2]) - self.check_do_query(3, 2, album=True) - self.check_do_query(0, 2, album=True, also_items=False) From f5a949fcf84f9671b131f84cda967ab6b8210bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 18 Aug 2026 19:18:07 +0100 Subject: [PATCH 4/4] modify: fix selecting objects --- beets/ui/commands/modify.py | 4 +--- docs/changelog.rst | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/beets/ui/commands/modify.py b/beets/ui/commands/modify.py index d5907755fe..7252852d88 100644 --- a/beets/ui/commands/modify.py +++ b/beets/ui/commands/modify.py @@ -98,9 +98,7 @@ def modify_objects( extra = "" selected_changes = ui.input_select_objects( - f"Really modify{extra}", - changed, - lambda o: print_and_modify(o, mods, dels), + f"Really modify{extra}", changed, ui.show_model_changes ) else: selected_changes = changed diff --git a/docs/changelog.rst b/docs/changelog.rst index 5fa420a1d8..86c7684236 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -69,6 +69,8 @@ Bug fixes text and the filters are empty. :bug:`6862` - :doc:`plugins/ipfs`: Fix ``beet ipfs --play`` option to invoke the Play plugin through its command interface. +- :ref:`modify-cmd`: Fix applying changes when choosing objects in interactive + select mode. :bug:`4880` .. For plugin developers