From 029de94b206bd64aecc24f24bf011413b86d78e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 20 Aug 2026 08:44:24 +0100 Subject: [PATCH 1/2] 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 | 156 ++++++++++++++++++++++---------- beets/ui/commands/__init__.py | 11 ++- beets/ui/commands/completion.py | 3 +- beets/ui/commands/config.py | 2 +- beets/ui/commands/fields.py | 6 +- beets/ui/commands/help.py | 2 +- beets/ui/commands/list.py | 6 +- beets/ui/commands/modify.py | 24 ++++- beets/ui/commands/move.py | 26 +++--- beets/ui/commands/remove.py | 12 ++- beets/ui/commands/stats.py | 4 +- beets/ui/commands/update.py | 12 ++- beets/ui/commands/utils.py | 13 ++- beets/ui/commands/write.py | 6 +- beets/util/config.py | 2 +- beets/util/extension.py | 4 +- beets/util/m3u.py | 16 +++- beets/util/pipeline.py | 5 +- beets/util/units.py | 6 +- 32 files changed, 297 insertions(+), 134 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..dfb3f08638 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, Sequence + 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: Sequence[bytes] = [], + query: Sequence[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 a89598187a..b394e60c9c 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, TypeVar, overload import confuse @@ -28,8 +28,13 @@ 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 + +T = TypeVar("T") # On Windows platforms, use colorama to support "ANSI" terminal colors. if sys.platform == "win32": @@ -47,17 +52,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 +83,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 +122,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 +131,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 +158,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 @@ -173,15 +178,46 @@ def input_(prompt=None): return resp +@overload +def input_options( + options: tuple[()], + require: bool = False, + prompt: str | None = None, + fallback_prompt: str | None = None, + *, + numrange: tuple[int, int], + default: str | None = None, + max_width: int = 72, +) -> int: ... +@overload +def input_options( + options: Sequence[str], + require: bool = False, + prompt: str | None = None, + fallback_prompt: str | None = None, + numrange: None = None, + default: str | None = None, + max_width: int = 72, +) -> str: ... +@overload 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] = ..., + default: str | None = None, + max_width: int = 72, +) -> str | int: ... +def input_options( + 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, +) -> str | int: """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 +283,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 +386,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 +398,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[T], + rep: Callable[[T], 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 +496,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 @@ -478,14 +523,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. """ @@ -513,7 +558,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. @@ -533,7 +578,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. @@ -563,7 +612,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() @@ -586,7 +635,14 @@ class Subcommand: func: Callable[[library.Library, Any, 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. @@ -600,18 +656,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}" @@ -623,7 +679,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. @@ -643,14 +699,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: @@ -702,7 +760,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. @@ -712,7 +770,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. """ @@ -725,7 +785,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. @@ -769,7 +831,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 @@ -834,7 +896,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 783f59e498..3d241a0e14 100644 --- a/beets/ui/commands/completion.py +++ b/beets/ui/commands/completion.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: import optparse + from collections.abc import Iterator, Sequence from beets.library import Library @@ -52,7 +53,7 @@ def print_completion( ] -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 449b237a1b..d335e8b822 100644 --- a/beets/ui/commands/config.py +++ b/beets/ui/commands/config.py @@ -59,7 +59,7 @@ def config_func(lib: Library, opts: ConfigCLIOpts, args: list[str]) -> None: print("Empty configuration") -def config_edit(cli_options): +def config_edit(cli_options: ConfigCLIOpts) -> 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..ced76294ad 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, Sequence from beets.library import Library -def _print_keys(query): +def _print_keys(query: Sequence[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 8511d9820d..cd573e5cfc 100644 --- a/beets/ui/commands/list.py +++ b/beets/ui/commands/list.py @@ -7,6 +7,8 @@ from beets import ui if TYPE_CHECKING: + from collections.abc import Sequence + from beets.library import Library @@ -14,7 +16,9 @@ class ListCLIOpts(Protocol): album: bool -def list_items(lib, query, album, fmt=""): +def list_items( + lib: Library, query: Sequence[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 8891e1c643..03ec5e12af 100644 --- a/beets/ui/commands/modify.py +++ b/beets/ui/commands/modify.py @@ -12,6 +12,8 @@ from .utils import do_query if TYPE_CHECKING: + from collections.abc import Sequence + from beets.library import LibModel, Library @@ -57,7 +59,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: Sequence[str], + query: Sequence[str], + write: bool, + move: bool, + album: bool, + confirm: bool, + inherit: bool, +) -> None: """Modifies matching items according to user-specified assignments and deletions. @@ -116,7 +128,11 @@ 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]] | dict[str, ModifyOperation], + dels: Sequence[str], +) -> bool: """Print the modifications to an item and return a bool indicating whether any changes were made. @@ -132,7 +148,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: Sequence[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 1d136be2eb..4b28497f65 100644 --- a/beets/ui/commands/move.py +++ b/beets/ui/commands/move.py @@ -13,7 +13,9 @@ from .utils import do_query if TYPE_CHECKING: - from beets.library import Library + from collections.abc import Iterable, Sequence + + from beets.library import Album, Item, Library # Global logger. log = logging.getLogger("beets") @@ -28,7 +30,7 @@ class MoveCLIOpts(Protocol): timid: bool -def show_path_changes(path_changes): +def show_path_changes(path_changes: Iterable[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. @@ -70,15 +72,15 @@ def show_path_changes(path_changes): def move_items( - lib, + lib: Library, dest: bytes | None, - query, - copy, - album, - pretend, - confirm=False, - export=False, -): + query: Sequence[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. @@ -88,10 +90,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 053d685960..48c70e43d4 100644 --- a/beets/ui/commands/remove.py +++ b/beets/ui/commands/remove.py @@ -9,7 +9,9 @@ from .utils import do_query if TYPE_CHECKING: - from beets.library import Library + from collections.abc import Sequence + + from beets.library import Album, Item, Library class RemoveCLIOpts(Protocol): @@ -18,7 +20,9 @@ class RemoveCLIOpts(Protocol): force: bool | None -def remove_items(lib, query, album, delete, force): +def remove_items( + lib: Library, query: Sequence[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. """ @@ -52,10 +56,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 4ff976f80f..a9d60f21b2 100644 --- a/beets/ui/commands/stats.py +++ b/beets/ui/commands/stats.py @@ -10,6 +10,8 @@ from beets.util.units import human_bytes, human_seconds if TYPE_CHECKING: + from collections.abc import Sequence + from beets.library import Library @@ -21,7 +23,7 @@ class StatsCLIOpts(Protocol): exact: bool -def show_stats(lib, query, exact): +def show_stats(lib: Library, query: Sequence[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 01900e9370..a2f13e475b 100644 --- a/beets/ui/commands/update.py +++ b/beets/ui/commands/update.py @@ -12,6 +12,8 @@ from .utils import do_query if TYPE_CHECKING: + from collections.abc import Sequence + from beets.library import Library @@ -27,7 +29,15 @@ class UpdateCLIOpts(Protocol): pretend: bool | None -def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): +def update_items( + lib: Library, + query: Sequence[str], + album: bool, + move: bool, + pretend: bool, + fields: list[str], + exclude_fields: Sequence[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..04a4367ea3 100644 --- a/beets/ui/commands/utils.py +++ b/beets/ui/commands/utils.py @@ -1,9 +1,20 @@ """Utility functions for beets UI commands.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from beets.exceptions import UserError +if TYPE_CHECKING: + from collections.abc import Sequence + + from beets.library import Album, Item, Library + -def do_query(lib, query, album, also_items=True): +def do_query( + lib: Library, query: Sequence[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/write.py b/beets/ui/commands/write.py index d4f3d88347..78e8dbaf97 100644 --- a/beets/ui/commands/write.py +++ b/beets/ui/commands/write.py @@ -11,6 +11,8 @@ from .utils import do_query if TYPE_CHECKING: + from collections.abc import Sequence + from beets.library import Library @@ -23,7 +25,9 @@ class WriteCLIOpts(Protocol): pretend: bool -def write_items(lib, query, pretend, force): +def write_items( + lib: Library, query: Sequence[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..16d9d9c536 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -1,9 +1,15 @@ """Provides utilities to read, write and manipulate m3u playlist files.""" +from __future__ import annotations + import traceback +from typing import TYPE_CHECKING from beets.util import FilesystemError, mkdirall, normpath, syspath +if TYPE_CHECKING: + from beets.util import PathLike + class EmptyPlaylistError(Exception): """Raised when a playlist file without media files is saved or loaded.""" @@ -12,7 +18,7 @@ class EmptyPlaylistError(Exception): class M3UFile: """Reads and writes m3u or m3u8 playlist files.""" - def __init__(self, path): + def __init__(self, path: PathLike) -> 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 +30,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 +50,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 +66,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..99d5caac57 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]), 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 01375c7392356b57856d6a322b983226ad1da2e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 22 Aug 2026 17:49:22 +0100 Subject: [PATCH 2/2] typing: fix core annotations --- beets/autotag/distance.py | 15 ++++-- beets/library/models.py | 9 ++-- beets/test/_common.py | 7 +-- beets/test/fixtures.py | 3 +- beets/test/helper.py | 3 ++ beets/ui/__init__.py | 81 ++++++++++++++-------------- beets/ui/commands/completion.py | 19 ++++--- beets/ui/commands/help.py | 1 + beets/ui/commands/import_/session.py | 11 ++-- beets/ui/commands/list.py | 9 ++-- beets/ui/commands/move.py | 22 ++++++-- beets/ui/commands/remove.py | 35 ++++++++---- beets/ui/commands/update.py | 22 +++++--- beets/ui/commands/utils.py | 2 +- beets/util/extension.py | 10 ++-- beets/util/m3u.py | 2 + beetsplug/edit.py | 2 +- test/test_importer.py | 6 +-- 18 files changed, 158 insertions(+), 101 deletions(-) diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index 52a51c52f9..41fea5df21 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -214,16 +214,25 @@ def __eq__(self, other: object) -> bool: # Behave like a float. def __lt__(self, other: object) -> bool: - return self.distance < other + if isinstance(other, (int, float, Distance)): + return self.distance < other + + return NotImplemented def __float__(self) -> float: return self.distance def __sub__(self, other: object) -> float: - return self.distance - other + if isinstance(other, (int, float, Distance)): + return self.distance - other + + return NotImplemented def __rsub__(self, other: object) -> float: - return other - self.distance + if isinstance(other, (int, float, Distance)): + return other - self.distance + + return NotImplemented def __str__(self) -> str: return f"{self.distance:.2f}" 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 dfb3f08638..f629e7b37e 100644 --- a/beets/test/_common.py +++ b/beets/test/_common.py @@ -21,6 +21,7 @@ import pytest + from beets.dbcore import Query from beets.library import Item, Library # Test resources path. @@ -81,10 +82,10 @@ 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: Sequence[bytes] = [], - query: Sequence[str] = [], + paths: Sequence[bytes] | None = None, + query: str | Sequence[str] | Query | None = None, cli: bool = False, ) -> importer.ImportSession: cls = ( 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 b394e60c9c..05878eb31f 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, TypeVar, overload +from typing import TYPE_CHECKING, Any, Literal, TextIO, TypeVar, overload import confuse @@ -297,24 +297,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])) @@ -356,34 +357,29 @@ def input_options( fallback_prompt += "{}-{}, ".format(*numrange) fallback_prompt += f"{', '.join(display_letters)}:" - resp = input_(prompt) + user_choice = input_(prompt) while True: - resp = resp.strip().lower() - + user_choice = user_choice.strip().lower() # Try default option. - if default is not None and not resp: - resp = default + if default_choice is not None and not user_choice: + choice = str(default_choice) + else: + choice = user_choice # Try an integer input if available. - if numrange: - try: - resp = int(resp) - except ValueError: - pass - else: - low, high = numrange - if low <= resp <= high: - return resp - resp = None - + if numrange and choice.isdigit(): + int_resp = int(choice) + low, high = numrange + if low <= int_resp <= high: + return int_resp # Try a normal letter input. - if resp: - resp = resp[0] - if resp in letters: - return resp + elif choice: + choice = choice[0] + if choice in letters: + return choice # Prompt for new input. - resp = input_(fallback_prompt) + user_choice = input_(fallback_prompt) def input_yn(prompt: str, require: bool = False) -> bool: @@ -496,6 +492,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 @@ -535,21 +533,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 @@ -581,7 +579,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. @@ -599,9 +597,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, @@ -634,6 +634,7 @@ class Subcommand: """ func: Callable[[library.Library, Any, list[str]], Any] + _root_parser: optparse.OptionParser | None def __init__( self, @@ -679,6 +680,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 @@ -771,7 +774,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 3d241a0e14..cb2d7ed04d 100644 --- a/beets/ui/commands/completion.py +++ b/beets/ui/commands/completion.py @@ -62,10 +62,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 = [] @@ -107,8 +107,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 @@ -116,13 +116,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/import_/session.py b/beets/ui/commands/import_/session.py index 729ed5c367..67776ebf92 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -400,6 +400,7 @@ def choose_candidate( choice_actions = {c.short: c for c in choices} # Zero candidates. + sel: str | int if not candidates: if source.type == "track": ui.print_("No matching recordings found.") @@ -460,16 +461,16 @@ def choose_candidate( # Ask the user for a choice. sel = ui.input_options(choice_opts, numrange=(1, len(candidates))) - if sel == "m": - pass - elif sel in choice_actions: - return choice_actions[sel] - else: # Numerical selection. + if isinstance(sel, int): # Numerical selection. match = candidates[sel - 1] if sel != 1: # When choosing anything but the first match, # disable the default action. require = True + elif sel == "m": + pass + elif sel in choice_actions: + return choice_actions[sel] bypass_candidates = False # Show what we're about to do. diff --git a/beets/ui/commands/list.py b/beets/ui/commands/list.py index cd573e5cfc..b1e4945a42 100644 --- a/beets/ui/commands/list.py +++ b/beets/ui/commands/list.py @@ -23,8 +23,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)) @@ -35,6 +35,9 @@ def list_func(lib: Library, opts: ListCLIOpts, 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 4b28497f65..02d8662179 100644 --- a/beets/ui/commands/move.py +++ b/beets/ui/commands/move.py @@ -5,6 +5,8 @@ import os from typing import TYPE_CHECKING, Protocol +from typing_extensions import TypeIs + from beets import logging, ui from beets.exceptions import UserError from beets.util import MoveOperation, displayable_path, normpath, syspath @@ -45,11 +47,11 @@ def show_path_changes(path_changes: Iterable[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 @@ -71,6 +73,12 @@ def show_path_changes(path_changes: Iterable[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: bytes | None, @@ -96,7 +104,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 = "" @@ -119,7 +131,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 48c70e43d4..ef0053ffe4 100644 --- a/beets/ui/commands/remove.py +++ b/beets/ui/commands/remove.py @@ -2,22 +2,24 @@ from __future__ import annotations +from functools import singledispatch from typing import TYPE_CHECKING, Protocol from beets import ui +from beets.library import Album, Item from .utils import do_query if TYPE_CHECKING: from collections.abc import Sequence - from beets.library import Album, Item, Library + from beets.library import LibModel, Library class RemoveCLIOpts(Protocol): album: bool - delete: bool | None - force: bool | None + delete: bool + force: bool def remove_items( @@ -55,16 +57,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: @@ -92,10 +97,18 @@ def remove_func(lib: Library, opts: RemoveCLIOpts, args: list[str]) -> None: "remove", help="remove matching items from the library", aliases=("rm",) ) remove_cmd.parser.add_option( - "-d", "--delete", action="store_true", help="also remove files from disk" + "-d", + "--delete", + action="store_true", + default=False, + help="also remove files from disk", ) remove_cmd.parser.add_option( - "-f", "--force", action="store_true", help="do not ask when removing items" + "-f", + "--force", + action="store_true", + default=False, + help="do not ask when removing items", ) remove_cmd.parser.add_album_option() remove_cmd.func = remove_func diff --git a/beets/ui/commands/update.py b/beets/ui/commands/update.py index a2f13e475b..880ca90a6c 100644 --- a/beets/ui/commands/update.py +++ b/beets/ui/commands/update.py @@ -12,7 +12,7 @@ from .utils import do_query if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Collection, Sequence from beets.library import Library @@ -26,16 +26,16 @@ class UpdateCLIOpts(Protocol): exclude_fields: list[str] | None fields: list[str] | None move: bool | None - pretend: bool | None + pretend: bool def update_items( lib: Library, query: Sequence[str], - album: bool, + is_album: bool, move: bool, pretend: bool, - fields: list[str], + fields: list[str] | None, exclude_fields: Sequence[str] | None = None, ) -> None: """For all the items matched by the query, update the library to @@ -45,8 +45,9 @@ def update_items( :param exclude_fields: The fields to not be stored. If not specified, all fields will be. """ + item_fields: Collection[str] 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 @@ -102,7 +103,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 is not None + 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") @@ -138,7 +143,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: @@ -199,6 +206,7 @@ def update_func(lib: Library, opts: UpdateCLIOpts, args: list[str]) -> None: "-p", "--pretend", action="store_true", + default=False, help="show all changes but do nothing", ) update_cmd.parser.add_option( diff --git a/beets/ui/commands/utils.py b/beets/ui/commands/utils.py index 04a4367ea3..8a5d3ccb1d 100644 --- a/beets/ui/commands/utils.py +++ b/beets/ui/commands/utils.py @@ -21,9 +21,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..1f2c7ff8d9 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. @@ -136,13 +134,13 @@ def fix_extension( new_path = path.with_suffix("." + detected_format) if not new_path.exists(): if beets.config["import"]["fix_ext_inplace"]: - util.move(bytes(path), bytes(new_path)) + util.move(path, new_path) else: - util.copy(bytes(path), bytes(new_path)) + util.copy(path, new_path) 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 16d9d9c536..be25e8b0f1 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -18,6 +18,8 @@ class EmptyPlaylistError(Exception): class M3UFile: """Reads and writes m3u or m3u8 playlist files.""" + media_list: list[bytes] + def __init__(self, path: PathLike) -> None: """``path`` is the absolute path to the playlist file. diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 2fb85e97d4..e9dcf2b768 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -566,7 +566,7 @@ def importer_edit_candidate(self, session, task): applied to the original items. """ # Prompt the user for a candidate. - sel = ui.input_options([], numrange=(1, len(task.candidates))) + sel = ui.input_options((), numrange=(1, len(task.candidates))) # Force applying the candidate on the items. task.match = task.candidates[sel - 1] task.apply_metadata() 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()