From 67edc81a2acaa80d7d383d97d3915dd36d4d4526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 17 Aug 2026 19:33:14 +0100 Subject: [PATCH 1/6] typing: add missing types to plugins --- beets/util/__init__.py | 2 +- beetsplug/_utils/vfs.py | 4 +- beetsplug/absubmit.py | 18 ++++-- beetsplug/advancedrewrite.py | 12 +++- beetsplug/albumtypes.py | 4 +- beetsplug/badfiles.py | 28 ++++---- beetsplug/bareasc.py | 13 ++-- beetsplug/bench.py | 6 +- beetsplug/bpm.py | 11 ++-- beetsplug/bpsync.py | 32 ++++++--- beetsplug/bucket.py | 54 +++++++++++----- beetsplug/convert.py | 4 +- beetsplug/duplicates.py | 63 +++++++++++------- beetsplug/edit.py | 61 +++++++++++------- beetsplug/embyupdate.py | 14 ++-- beetsplug/fetchart.py | 6 +- beetsplug/filefilter.py | 2 +- beetsplug/fish.py | 32 +++++---- beetsplug/freedesktop.py | 2 +- beetsplug/fromfilename.py | 18 ++++-- beetsplug/fuzzy.py | 8 ++- beetsplug/hook.py | 4 +- beetsplug/ihate.py | 6 +- beetsplug/importadded.py | 8 +-- beetsplug/importfeeds.py | 14 ++-- beetsplug/importsource.py | 2 +- beetsplug/info.py | 37 +++++++---- beetsplug/inline.py | 27 ++++++-- beetsplug/ipfs.py | 34 +++++----- beetsplug/keyfinder.py | 12 ++-- beetsplug/kodiupdate.py | 4 +- beetsplug/lastgenre/client.py | 2 +- beetsplug/lastimport.py | 31 ++++++--- beetsplug/limit.py | 8 ++- beetsplug/lyrics.py | 22 ++++--- beetsplug/mbcollection.py | 2 +- beetsplug/mbpseudo.py | 15 +++-- beetsplug/mbsync.py | 24 +++++-- beetsplug/metasync/__init__.py | 17 +++-- beetsplug/metasync/amarok.py | 18 ++++-- beetsplug/metasync/itunes.py | 20 ++++-- beetsplug/missing.py | 13 ++-- beetsplug/mpdupdate.py | 15 +++-- beetsplug/parentwork.py | 15 +++-- beetsplug/permissions.py | 15 +++-- beetsplug/play.py | 37 ++++++----- beetsplug/playlist.py | 6 +- beetsplug/plexupdate.py | 22 +++++-- beetsplug/random.py | 4 +- beetsplug/replace.py | 13 ++-- beetsplug/replaygain.py | 114 +++++++++++++++++++-------------- beetsplug/rewrite.py | 28 +++++--- beetsplug/scrub.py | 12 ++-- beetsplug/smartplaylist.py | 6 +- beetsplug/subsonicplaylist.py | 32 ++++++--- beetsplug/subsonicupdate.py | 4 +- beetsplug/substitute.py | 4 +- beetsplug/the.py | 6 +- beetsplug/titlecase.py | 2 +- beetsplug/types.py | 8 +-- beetsplug/unimported.py | 4 +- beetsplug/zero.py | 19 +++--- docs/conf.py | 4 +- extra/release.py | 4 +- 64 files changed, 692 insertions(+), 396 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 604d4f6687..d4c89c2a99 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -850,7 +850,7 @@ class CommandOutput(NamedTuple): def command_output( - cmd: list[str] | list[bytes], shell: bool = False + cmd: Sequence[str] | Sequence[bytes], shell: bool = False ) -> CommandOutput: """Runs the command and returns its output after it has exited. diff --git a/beetsplug/_utils/vfs.py b/beetsplug/_utils/vfs.py index 58b626d9fe..6e99558318 100644 --- a/beetsplug/_utils/vfs.py +++ b/beetsplug/_utils/vfs.py @@ -9,6 +9,8 @@ from beets import util if TYPE_CHECKING: + from collections.abc import Sequence + from beets.library import Library @@ -20,7 +22,7 @@ class Node(NamedTuple): # Maps directory names to child nodes. -def _insert(node: Node, path: list[str], itemid: int): +def _insert(node: Node, path: Sequence[str], itemid: int) -> None: """Insert an item into a virtual filesystem node.""" if len(path) == 1: # Last component. Insert file. diff --git a/beetsplug/absubmit.py b/beetsplug/absubmit.py index b043d8ac17..cd0d5c05dd 100644 --- a/beetsplug/absubmit.py +++ b/beetsplug/absubmit.py @@ -17,7 +17,11 @@ from beets.exceptions import UserError if TYPE_CHECKING: - from beets.library import Library + from collections.abc import Sequence + + from beets.library import Item, Library + + from ._typing import JSONDict class ABSubmitCLIOpts(Protocol): @@ -33,7 +37,7 @@ class ABSubmitError(Exception): """Raised when failing to analyse file with extractor.""" -def call(args): +def call(args: Sequence[str]) -> bytes: """Execute the command and return its output. Raise a AnalysisABSubmitError on failure. @@ -45,7 +49,7 @@ def call(args): class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self._log.warning("This plugin is deprecated.") @@ -99,7 +103,7 @@ def __init__(self): base_url = f"{base_url}/" self.url = f"{base_url}{{mbid}}/low-level" - def commands(self): + def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand( "absubmit", help="calculate and submit AcousticBrainz analysis" ) @@ -139,12 +143,12 @@ def command( self.opts = opts util.par_map(self.analyze_submit, items) - def analyze_submit(self, item): + def analyze_submit(self, item: Item) -> None: analysis = self._get_analysis(item) if analysis: self._submit_data(item, analysis) - def _get_analysis(self, item): + def _get_analysis(self, item: Item) -> JSONDict | None: mbid = item["mb_trackid"] # Avoid re-analyzing files that already have AB data. @@ -195,7 +199,7 @@ def _get_analysis(self, item): if e.errno != errno.ENOENT: raise - def _submit_data(self, item, data): + def _submit_data(self, item: Item, data: JSONDict) -> None: mbid = item["mb_trackid"] headers = {"Content-Type": "application/json"} response = requests.post( diff --git a/beetsplug/advancedrewrite.py b/beetsplug/advancedrewrite.py index 03267e5bc1..6a23c940a7 100644 --- a/beetsplug/advancedrewrite.py +++ b/beetsplug/advancedrewrite.py @@ -18,7 +18,9 @@ from .rewrite import apply_rewrite_rules if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Callable, Iterator + + from beets.library import LibModel class AdvancedRewriteConfig(TypedDict): @@ -26,7 +28,11 @@ class AdvancedRewriteConfig(TypedDict): replacements: dict[str, str | list[str]] -def rewriter(field, simple_rules, advanced_rules): +def rewriter( + field: str, + simple_rules: list[tuple[re.Pattern[str], str]], + advanced_rules: list[tuple[AndQuery, str | list[str]]], +) -> Callable[[LibModel], str]: """Template field function factory. Create a template field function that rewrites the given field @@ -35,7 +41,7 @@ def rewriter(field, simple_rules, advanced_rules): ``advanced_rules`` must be a list of (query, replacement) pairs. """ - def fieldfunc(item): + def fieldfunc(item: LibModel) -> str: value = item._values_fixed[field] if (new_value := apply_rewrite_rules(value, simple_rules)) != value: # Rewrite activated. diff --git a/beetsplug/albumtypes.py b/beetsplug/albumtypes.py index c3768b280d..499c506417 100644 --- a/beetsplug/albumtypes.py +++ b/beetsplug/albumtypes.py @@ -15,7 +15,7 @@ class AlbumTypesPlugin(BeetsPlugin): """Adds an album template field for formatted album types.""" - def __init__(self): + def __init__(self) -> None: """Init AlbumTypesPlugin.""" super().__init__() self.album_template_fields["atypes"] = self._atypes @@ -34,7 +34,7 @@ def __init__(self): } ) - def _atypes(self, item: Album): + def _atypes(self, item: Album) -> str: """Returns a formatted string based on album's types.""" types = self.config["types"].as_pairs() ignore_va = self.config["ignore_va"].as_str_seq() diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index b2f9886d7a..058a6e68e1 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -18,8 +18,10 @@ from beets.util.color import colorize if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from beets.importer import ImportSession, ImportTask - from beets.library import Library + from beets.library import Item, Library ImportAction = Literal["abort", "skip", "continue"] @@ -38,7 +40,7 @@ class CheckerCommandError(Exception): msg: Message from the checker execution error. """ - def __init__(self, cmd, oserror) -> None: + def __init__(self, cmd: Sequence[str], oserror: OSError) -> None: self.checker = cmd[0] self.path = cmd[-1] self.errno = oserror.errno @@ -63,7 +65,7 @@ def __init__(self) -> None: "import_task_before_choice", self.on_import_task_before_choice ) - def run_command(self, cmd): + def run_command(self, cmd: Sequence[str]) -> tuple[int, int, list[str]]: self._log.debug( "running command: {}", displayable_path(list2cmdline(cmd)) ) @@ -80,25 +82,29 @@ def run_command(self, cmd): output = output.decode(sys.getdefaultencoding(), "replace") return status, errors, [line for line in output.split("\n") if line] - def check_mp3val(self, path): + def check_mp3val(self, path: str) -> tuple[int, int, list[str]]: status, errors, output = self.run_command(["mp3val", path]) if status == 0: output = [line for line in output if line.startswith("WARNING:")] errors = len(output) return status, errors, output - def check_flac(self, path): + def check_flac(self, path: str) -> tuple[int, int, list[str]]: return self.run_command(["flac", "-wst", path]) - def check_custom(self, command): - def checker(path): + def check_custom( + self, command: str + ) -> Callable[[str], tuple[int, int, list[str]]]: + def checker(path: str): cmd = shlex.split(command) cmd.append(path) return self.run_command(cmd) return checker - def get_checker(self, ext): + def get_checker( + self, ext: str + ) -> Callable[[str], tuple[int, int, list[str]]] | None: ext = ext.lower() try: command = self.config["commands"].get(dict).get(ext) @@ -112,7 +118,7 @@ def get_checker(self, ext): return self.check_flac return None - def check_item(self, item): + def check_item(self, item: Item) -> list[str]: # First, check whether the path exists. If not, the user # should probably run `beet update` to cleanup your library. dpath = displayable_path(item.path) @@ -259,13 +265,13 @@ def command(self, lib: Library, opts: BadCLIOpts, args: list[str]) -> None: items = lib.items(args) self.verbose = opts.verbose - def check_and_print(item): + def check_and_print(item: Item) -> None: for error_line in self.check_item(item): ui.print_(error_line) par_map(check_and_print, items) - def commands(self): + def commands(self) -> list[Subcommand]: bad_command = Subcommand( "bad", help="check for corrupt or missing files" ) diff --git a/beetsplug/bareasc.py b/beetsplug/bareasc.py index 9b40e0ff5a..9f4f7704e2 100644 --- a/beetsplug/bareasc.py +++ b/beetsplug/bareasc.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Any, Protocol from unidecode import unidecode @@ -16,6 +16,7 @@ from beets.ui import print_ if TYPE_CHECKING: + from beets.dbcore.query import FieldQuery from beets.library import Library @@ -27,7 +28,7 @@ class BareascQuery(StringFieldQuery[str]): """Compare items using bare ASCII, without accents etc.""" @classmethod - def string_match(cls, pattern, val): + def string_match(cls, pattern: str, val: str) -> bool: """Convert both pattern and string to plain ASCII before matching. If pattern is all lower case, also convert string to lower case so @@ -40,7 +41,7 @@ def string_match(cls, pattern, val): val = unidecode(val) return pattern in val - def col_clause(self): + def col_clause(self) -> tuple[str, list[str]]: """Compare ascii version of the pattern.""" clause = f"unidecode({self.field})" if self.pattern.islower(): @@ -52,17 +53,17 @@ def col_clause(self): class BareascPlugin(BeetsPlugin): """Plugin to provide bare-ASCII option for beets matching.""" - def __init__(self): + def __init__(self) -> None: """Default prefix for selecting bare-ASCII matching is #.""" super().__init__() self.config.add({"prefix": "#"}) - def queries(self): + def queries(self) -> dict[str, type[FieldQuery[Any]]]: """Register bare-ASCII matching.""" prefix = self.config["prefix"].as_str() return {prefix: BareascQuery} - def commands(self): + def commands(self) -> list[ui.Subcommand]: """Add bareasc command as unidecode version of 'list'.""" cmd = ui.Subcommand( "bareasc", help="unidecode version of beet list command" diff --git a/beetsplug/bench.py b/beetsplug/bench.py index f058084777..0da53533af 100644 --- a/beetsplug/bench.py +++ b/beetsplug/bench.py @@ -30,7 +30,7 @@ class BenchMatch(Protocol): def aunique_benchmark( lib: Library, opts: BenchAunique, args: list[str] ) -> None: - def _build_tree(): + def _build_tree() -> None: vfs.libtree(lib) # Measure path generation performance with %aunique{} included. @@ -80,7 +80,7 @@ def match_benchmark(lib: Library, opts: BenchMatch, args: list[str]) -> None: ) # Run the match. - def _run_match(): + def _run_match() -> None: source = Source.from_items(items) tag_album(source, search_ids=[id_]) @@ -96,7 +96,7 @@ def _run_match(): class BenchmarkPlugin(BeetsPlugin): """A plugin for performing some simple performance benchmarks.""" - def commands(self): + def commands(self) -> list[ui.Subcommand]: aunique_bench_cmd = ui.Subcommand( "bench_aunique", help="benchmark for %aunique{}" ) diff --git a/beetsplug/bpm.py b/beetsplug/bpm.py index f9f6a461e8..e57f9b5aa7 100644 --- a/beetsplug/bpm.py +++ b/beetsplug/bpm.py @@ -10,11 +10,12 @@ if TYPE_CHECKING: import optparse + from collections.abc import Sequence - from beets.library import Library + from beets.library import Item, Library -def bpm(max_strokes): +def bpm(max_strokes: int) -> float: """Returns average BPM (possibly of a playing song) listening to Enter keystrokes. """ @@ -38,11 +39,11 @@ def bpm(max_strokes): class BPMPlugin(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add({"max_strokes": 3, "overwrite": True}) - def commands(self): + def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand( "bpm", help="determine bpm of a song by pressing a key to the rhythm", @@ -56,7 +57,7 @@ def command( write = ui.should_write() self.get_bpm(lib.items(args), write) - def get_bpm(self, items, write=False): + def get_bpm(self, items: Sequence[Item], write: bool = False) -> None: overwrite = self.config["overwrite"].get(bool) if len(items) > 1: raise ValueError("Can only get bpm of one song at time") diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py index 956294bb2c..3246e004fd 100644 --- a/beetsplug/bpsync.py +++ b/beetsplug/bpsync.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Literal, Protocol from beets import library, ui, util from beets.autotag import AlbumMatch, Distance, TrackMatch @@ -12,7 +12,9 @@ from .beatport import BeatportPlugin if TYPE_CHECKING: - from beets.library import Library + from collections.abc import Sequence + + from beets.library import Album, Item, Library class BPSyncCLIOpts(Protocol): @@ -22,13 +24,13 @@ class BPSyncCLIOpts(Protocol): class BPSyncPlugin(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() deprecate_for_user(self._log, "The 'bpsync' plugin") self.beatport_plugin = BeatportPlugin() self.beatport_plugin.setup() - def commands(self): + def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand("bpsync", help="update metadata from Beatport") cmd.parser.add_option( "-p", @@ -71,7 +73,14 @@ def func(self, lib: Library, opts: BPSyncCLIOpts, args: list[str]) -> None: self.singletons(lib, args, move, pretend, write) self.albums(lib, args, move, pretend, write) - def singletons(self, lib, query, move, pretend, write): + def singletons( + self, + lib: Library, + query: Sequence[str], + move: bool, + pretend: bool, + write: bool, + ) -> None: """Retrieve and apply info from the autotagger for items matched by query. """ @@ -99,13 +108,13 @@ def singletons(self, lib, query, move, pretend, write): apply_item_changes(lib, item, move, pretend, write) @staticmethod - def is_beatport_track(item): + def is_beatport_track(item: Item) -> bool: return ( item.get("data_source") == BeatportPlugin.data_source and item.mb_trackid.isnumeric() ) - def get_album_tracks(self, album): + def get_album_tracks(self, album: Album) -> list[Item] | Literal[False]: if not album.mb_albumid: self._log.info("Skipping album with no mb_albumid: {}", album) return False @@ -128,7 +137,14 @@ def get_album_tracks(self, album): return False return items - def albums(self, lib, query, move, pretend, write): + def albums( + self, + lib: Library, + query: Sequence[str], + move: bool, + pretend: bool, + write: bool, + ) -> None: """Retrieve and apply info from the autotagger for albums matched by query and their items. """ diff --git a/beetsplug/bucket.py b/beetsplug/bucket.py index 82fa6898af..9745cc5353 100644 --- a/beetsplug/bucket.py +++ b/beetsplug/bucket.py @@ -1,31 +1,47 @@ """Provides the %bucket{} function for path formatting.""" +from __future__ import annotations + import re import string from datetime import datetime from itertools import tee +from typing import TYPE_CHECKING, TypedDict, TypeVar from beets import plugins from beets.exceptions import UserError +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + ASCII_DIGITS = string.digits + string.ascii_lowercase +T = TypeVar("T") +YearSpan = TypedDict( + "YearSpan", {"from": int, "to": int, "str": str}, total=False +) + + +class SpanFormat(TypedDict): + fromnchars: int + tonchars: int + fmt: str class BucketError(Exception): pass -def pairwise(iterable): +def pairwise(iterable: Iterable[T]) -> Iterator[tuple[T, T]]: "s -> (s0,s1), (s1,s2), (s2, s3), ..." a, b = tee(iterable) next(b, None) return zip(a, b) -def span_from_str(span_str): +def span_from_str(span_str: str) -> YearSpan: """Build a span dict from the span string representation.""" - def normalize_year(d, yearfrom): + def normalize_year(d: int, yearfrom: int) -> int: """Convert string to a 4 digits year""" if yearfrom < 100: raise BucketError(f"{yearfrom} must be expressed on 4 digits") @@ -51,13 +67,13 @@ def normalize_year(d, yearfrom): f"invalid range defined for year bucket {span_str!r}: {exc}" ) - res = {"from": years[0], "str": span_str} + res: YearSpan = {"from": years[0], "str": span_str} if len(years) > 1: res["to"] = years[-1] return res -def complete_year_spans(spans): +def complete_year_spans(spans: list[YearSpan]) -> None: """Set the `to` value of spans if empty and sort them chronologically.""" spans.sort(key=lambda x: x["from"]) for x, y in pairwise(spans): @@ -67,7 +83,9 @@ def complete_year_spans(spans): spans[-1]["to"] = datetime.now().year -def extend_year_spans(spans, spanlen, start=1900, end=2014): +def extend_year_spans( + spans: list[YearSpan], spanlen: int, start: int = 1900, end: int = 2014 +) -> list[YearSpan]: """Add new spans to given spans list so that every year of [start,end] belongs to a span. """ @@ -88,7 +106,7 @@ def extend_year_spans(spans, spanlen, start=1900, end=2014): return extended_spans -def build_year_spans(year_spans_str): +def build_year_spans(year_spans_str: Iterable[str]) -> list[YearSpan]: """Build a chronologically ordered list of spans dict from unordered spans stringlist. """ @@ -99,7 +117,7 @@ def build_year_spans(year_spans_str): return spans -def str2fmt(s): +def str2fmt(s: str) -> SpanFormat: """Deduces formatting syntax from a span string.""" regex = re.compile( r"(?P\D*)(?P\d+)(?P\D*)" @@ -117,7 +135,9 @@ def str2fmt(s): return res -def format_span(fmt, yearfrom, yearto, fromnchars, tonchars): +def format_span( + fmt: str, yearfrom: int, yearto: int, fromnchars: int, tonchars: int +) -> str: """Return a span string representation.""" args = [str(yearfrom)[-fromnchars:]] if tonchars: @@ -126,7 +146,7 @@ def format_span(fmt, yearfrom, yearto, fromnchars, tonchars): return fmt.format(*args) -def extract_modes(spans): +def extract_modes(spans: Iterable[YearSpan]) -> tuple[int, SpanFormat]: """Extract the most common spans lengths and representation formats""" rangelen = sorted([x["to"] - x["from"] + 1 for x in spans]) deflen = sorted(rangelen, key=rangelen.count)[-1] @@ -135,7 +155,9 @@ def extract_modes(spans): return deflen, deffmt -def build_alpha_spans(alpha_spans_str, alpha_regexs): +def build_alpha_spans( + alpha_spans_str: Iterable[str], alpha_regexs: dict[str, str] +) -> list[re.Pattern[str]]: """Extract alphanumerics from string and return sorted list of chars [from...to] """ @@ -164,7 +186,7 @@ def build_alpha_spans(alpha_spans_str, alpha_regexs): class BucketPlugin(plugins.BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.template_funcs["bucket"] = self._tmpl_bucket @@ -178,7 +200,7 @@ def __init__(self): ) self.setup() - def setup(self): + def setup(self) -> None: """Setup plugin from config options""" self.year_spans = build_year_spans(self.config["bucket_year"].get()) if self.year_spans and self.config["extrapolate"]: @@ -194,7 +216,7 @@ def setup(self): self.config["bucket_alpha_regex"].get(), ) - def find_bucket_year(self, year): + def find_bucket_year(self, year: str) -> str: """Return bucket that matches given year or return the year if no matching bucket. """ @@ -211,7 +233,7 @@ def find_bucket_year(self, year): ) return year - def find_bucket_alpha(self, s): + def find_bucket_alpha(self, s: str) -> str: """Return alpha-range bucket that matches given string or return the string initial if no matching bucket. """ @@ -220,7 +242,7 @@ def find_bucket_alpha(self, s): return self.config["bucket_alpha"].get()[i] return s[0].upper() - def _tmpl_bucket(self, text, field=None): + def _tmpl_bucket(self, text: str, field: str | None = None) -> str: if not field and len(text) == 4 and text.isdigit(): field = "year" diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 6a20d46012..6c21453db8 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -26,6 +26,8 @@ from beetsplug._utils import art if TYPE_CHECKING: + from collections.abc import Iterable + from beets.importer import ImportSession, ImportTask from beets.library import Album, Library from beets.util.pathformats import PathFormat @@ -778,7 +780,7 @@ def _cleanup(self, task: ImportTask, session: ImportSession) -> None: util.remove(path) _temp_files.remove(path) - def _parallel_convert(self, items: list[Item], keep_new: bool): + def _parallel_convert(self, items: Iterable[Item], keep_new: bool) -> None: """Run the convert_item function for every items on as many thread as defined in threads """ diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py index 8bfeffd467..f4e4879f76 100644 --- a/beetsplug/duplicates.py +++ b/beetsplug/duplicates.py @@ -4,7 +4,7 @@ import os import shlex -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from beets.library import Album, Item from beets.plugins import BeetsPlugin @@ -19,9 +19,9 @@ if TYPE_CHECKING: import optparse - from collections.abc import Sequence + from collections.abc import Iterator, Sequence - from beets.library import LibModel, Library + from beets.library import AlbumOrItem, LibModel, Library PLUGIN = "duplicates" @@ -30,7 +30,7 @@ class DuplicatesPlugin(BeetsPlugin): """List duplicate tracks or albums""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add( @@ -137,7 +137,7 @@ def __init__(self): ) self._command.parser.add_all_common_options() - def commands(self): + def commands(self) -> list[Subcommand]: def _dup(lib: Library, opts: optparse.Values, args: list[str]) -> None: self.config.set_args(opts) album = self.config["album"].get(bool) @@ -213,14 +213,15 @@ def _dup(lib: Library, opts: optparse.Values, args: list[str]) -> None: def _process_item( self, - item, - copy=False, - move=False, - delete=False, - tag=False, - fmt="", - remove=False, - ): + item: Item, + *, + copy: bool, + move: bytes, + delete: bool, + tag: str, + fmt: str, + remove: bool, + ) -> None: """Process Item `item`.""" print_(format(item, fmt)) if copy: @@ -241,7 +242,7 @@ def _process_item( setattr(item, k, v) item.store() - def _checksum(self, item, prog): + def _checksum(self, item: Item, prog: str) -> tuple[str, Any]: """Run external `prog` on file path associated with `item`, cache output as flexattr on a key that is the name of the program, and return the key, checksum tuple. @@ -274,7 +275,9 @@ def _checksum(self, item, prog): ) return key, checksum - def _group_by(self, objs, keys, strict): + def _group_by( + self, objs: Sequence[AlbumOrItem], keys: Sequence[str], strict: bool + ) -> dict[tuple[Any, ...], list[LibModel]]: """Return a dictionary with keys arbitrary concatenations of attributes and values lists of objects (Albums or Items) with those keys. @@ -304,7 +307,11 @@ def _group_by(self, objs, keys, strict): return counts - def _order(self, objs, tiebreak=None): + def _order( + self, + objs: Sequence[AlbumOrItem], + tiebreak: dict[str, list[str]] | None = None, + ) -> list[LibModel]: """Return the objects (Items or Albums) sorted by descending order of priority. @@ -317,12 +324,12 @@ def _order(self, objs, tiebreak=None): if tiebreak and kind in tiebreak.keys(): - def key(x): + def key(x: AlbumOrItem) -> tuple[Any, ...]: return tuple(getattr(x, k) for k in tiebreak[kind]) else: if kind == "items": - def truthy(v): + def truthy(v: object) -> bool: # Avoid a Unicode warning by avoiding comparison # between a bytes object and the empty Unicode # string ''. @@ -332,16 +339,16 @@ def truthy(v): fields = Item.all_keys() - def key(x): + def key(x: AlbumOrItem) -> int: return sum(1 for f in fields if truthy(getattr(x, f))) else: - def key(x): + def key(x: AlbumOrItem) -> int: return len(x.items()) return sorted(objs, key=key, reverse=True) - def _merge_items(self, objs): + def _merge_items(self, objs: Sequence[AlbumOrItem]) -> Sequence[Item]: """Merge Item objs by copying missing fields from items in the tail to the head item. @@ -365,7 +372,7 @@ def _merge_items(self, objs): break return objs - def _merge_albums(self, objs): + def _merge_albums(self, objs: Sequence[Album]) -> Sequence[Album]: """Merge Album objs by copying missing items from albums in the tail to the head album. @@ -388,7 +395,7 @@ def _merge_albums(self, objs): missing.move(operation=MoveOperation.COPY) return objs - def _merge(self, objs): + def _merge(self, objs: Sequence[AlbumOrItem]) -> Sequence[LibModel]: """Merge duplicate items. See ``_merge_items`` and ``_merge_albums`` for the relevant strategies. """ @@ -399,7 +406,15 @@ def _merge(self, objs): objs = self._merge_albums(objs) return objs - def _duplicates(self, objs, keys, full, strict, tiebreak, merge): + def _duplicates( + self, + objs: Sequence[AlbumOrItem], + keys: Sequence[str], + full: bool, + strict: bool, + tiebreak: dict[str, list[str]] | None, + merge: bool, + ) -> Iterator[tuple[tuple[Any, ...], int, Sequence[LibModel]]]: """Generate triples of keys, duplicate counts, and constituent objects.""" offset = 0 if full else 1 for k, objs in self._group_by(objs, keys, strict).items(): diff --git a/beetsplug/edit.py b/beetsplug/edit.py index b1af19eac8..80ec8d4219 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -8,7 +8,7 @@ import subprocess from collections import Counter from tempfile import NamedTemporaryFile -from typing import TYPE_CHECKING, Any, Protocol, cast +from typing import TYPE_CHECKING, Protocol, cast import yaml @@ -20,8 +20,13 @@ from beets.util import PromptChoice if TYPE_CHECKING: + from collections.abc import Container, Iterable, Sequence + from beets.importer import ImportSession, ImportTask - from beets.library import Library + from beets.library import LibModel, Library + from beets.logging import BeetsLogger as Logger + + from ._typing import JSONDict # These "safe" types can avoid the format/parse cycle that most fields go # through: they are safe to edit with native YAML types. @@ -51,7 +56,7 @@ class ParseError(Exception): """ -def edit(filename, log): +def edit(filename: str, log: Logger) -> None: """Open `filename` in a text editor.""" cmd = shlex.split(util.editor_command()) cmd.append(filename) @@ -62,12 +67,12 @@ def edit(filename, log): raise UserError(f"could not run editor command {cmd[0]!r}: {exc}") -def dump(arg): +def dump(arg: Sequence[JSONDict]) -> str: """Dump a sequence of dictionaries as YAML for editing.""" return yaml.safe_dump_all(arg, allow_unicode=True, default_flow_style=False) -def load(s): +def load(s: str) -> list[JSONDict]: """Read a sequence of YAML documents back to a list of dictionaries with string keys. @@ -90,7 +95,7 @@ def load(s): return out -def _safe_value(obj, key, value): +def _safe_value(obj: LibModel, key: str, value: str) -> bool: """Check whether the `value` is safe to represent in YAML and trust as returned from parsed YAML. @@ -103,7 +108,7 @@ def _safe_value(obj, key, value): return isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type) -def flatten(obj, fields): +def flatten(obj: LibModel, fields: Container[str] | None) -> JSONDict: """Represent `obj`, a `dbcore.Model` object, as a dictionary for serialization. Only include the given `fields` if provided; otherwise, include everything. @@ -128,7 +133,7 @@ def flatten(obj, fields): return d -def apply_(obj, data): +def apply_(obj: LibModel, data: JSONDict) -> None: """Set the fields of a `dbcore.Model` object according to a dictionary. @@ -164,7 +169,7 @@ def __init__(self) -> None: "before_choose_candidate", self.before_choose_candidate_listener ) - def commands(self): + def commands(self) -> list[ui.Subcommand]: edit_command = ui.Subcommand("edit", help="interactively edit metadata") edit_command.parser.add_option( "-f", @@ -197,7 +202,7 @@ def _edit_command( fields = self._get_fields(opts.album, opts.field) self.edit(opts.album, objs, fields) - def _get_fields(self, album, extra): + def _get_fields(self, album: bool, extra: list[str] | None) -> set[str]: """Get the set of fields to edit.""" # Start with the configured base fields. if album: @@ -214,7 +219,12 @@ def _get_fields(self, album, extra): return set(fields) - def edit(self, album, objs, fields): + def edit( + self, + album: bool, + objs: Sequence[LibModel], + fields: Container[str] | None, + ) -> None: """The core editor function. - `album`: A flag indicating whether we're editing Items or Albums. @@ -229,7 +239,9 @@ def edit(self, album, objs, fields): if success: self.save_changes(objs) - def edit_objects(self, objs, fields): + def edit_objects( + self, objs: Sequence[LibModel], fields: Container[str] | None + ) -> bool | None: """Dump a set of Model objects to a file as text, ask the user to edit it, and apply any changes to the objects. @@ -271,7 +283,12 @@ def edit_objects(self, objs, fields): cur_str = new_str continue - def apply_data(self, objs, old_data, new_data): + def apply_data( + self, + objs: Sequence[LibModel], + old_data: Sequence[JSONDict], + new_data: Sequence[JSONDict], + ) -> None: """Take potentially-updated data and apply it to a set of Model objects. @@ -324,7 +341,7 @@ def apply_data(self, objs, old_data, new_data): apply_(obj, new_dict) - def save_changes(self, objs): + def save_changes(self, objs: Sequence[LibModel]) -> None: """Save a list of updated Model objects to the database.""" # Save to the database and possibly write tags. for ob in objs: @@ -350,9 +367,7 @@ def before_choose_candidate_listener( return choices - def _importer_edit_album_header( - self, task: ImportTask - ) -> dict[str, Any] | None: + def _importer_edit_album_header(self, task: ImportTask) -> JSONDict | None: """Build the album-header YAML document for import editing. Returns a dict of album-level fields, or ``None`` when the current @@ -385,7 +400,7 @@ def _importer_edit_album_header( return header if header else None def _importer_edit_apply_header( - self, items: list[Item], header_data: dict[str, Any] + self, items: Iterable[Item], header_data: JSONDict ) -> None: """Apply album-header changes to every item in the list.""" if not header_data: @@ -393,9 +408,7 @@ def _importer_edit_apply_header( for item in items: apply_(item, header_data) - def _edit_yaml( - self, old_str: str - ) -> tuple[list[dict[str, Any]], str] | None: + def _edit_yaml(self, old_str: str) -> tuple[list[JSONDict], str] | None: """Open a temporary file with `old_str`, let the user edit it, and return the parsed list of YAML documents. @@ -546,7 +559,7 @@ def _importer_edit_cleanup(task: ImportTask) -> None: @staticmethod def _importer_edit_restore_from_copies( - task: ImportTask, copies: list[Item] + task: ImportTask, copies: Sequence[Item] ) -> None: """Restore items to their state before the last edit cycle. @@ -558,7 +571,9 @@ def _importer_edit_restore_from_copies( for key in item._fields: item[key] = copies[i][key] - def importer_edit_candidate(self, session, task): + def importer_edit_candidate( + self, session: ImportSession, task: ImportTask + ) -> Action | None: """Callback for invoking the functionality during an interactive import session on a *candidate*. The candidate's metadata is applied to the original items. diff --git a/beetsplug/embyupdate.py b/beetsplug/embyupdate.py index d696d6e1bf..8e978b1cf6 100644 --- a/beetsplug/embyupdate.py +++ b/beetsplug/embyupdate.py @@ -21,8 +21,10 @@ if TYPE_CHECKING: from beets.library import LibModel, Library + from ._typing import JSONDict -def api_url(host, port, endpoint): + +def api_url(host: str, port: int, endpoint: str) -> str: """Returns a joined url. Takes host, port and endpoint and generates a valid emby API url. @@ -55,7 +57,7 @@ def api_url(host, port, endpoint): return urlunsplit((scheme, netloc, path, new_query_string, fragment)) -def password_data(username, password): +def password_data(username: str, password: str) -> JSONDict: """Returns a dict with username and its encoded password. :param username: Emby username @@ -72,7 +74,7 @@ def password_data(username, password): } -def create_headers(user_id, token=None): +def create_headers(user_id: str, token: str | None = None) -> dict[str, str]: """Return header dict that is needed to talk to the Emby API. :param user_id: Emby user ID @@ -100,7 +102,9 @@ def create_headers(user_id, token=None): return headers -def get_token(host, port, headers, auth_data): +def get_token( + host: str, port: int, headers: dict[str, str], auth_data: JSONDict +) -> str | None: """Return token for a user. :param host: Emby host @@ -120,7 +124,7 @@ def get_token(host, port, headers, auth_data): return r.json().get("AccessToken") -def get_user(host, port, username): +def get_user(host: str, port: int, username: str) -> list[JSONDict]: """Return user dict from server or None if there is no user. :param host: Emby host diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 02f4c84eb4..ad406a004a 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -619,7 +619,7 @@ def get( album: Album, plugin: FetchArtPlugin, paths: Sequence[bytes] | None, - ): + ) -> Iterator[Any]: """Return art URL from AlbumArt.org using album ASIN.""" if not album.asin: return @@ -651,7 +651,7 @@ def __init__(self, *args, **kwargs) -> None: self.cx = (self._config["google_engine"].get(),) @staticmethod - def add_default_config(config: confuse.ConfigView): + def add_default_config(config: confuse.ConfigView) -> None: config.add( { "google_key": None, @@ -728,7 +728,7 @@ def __init__(self, *args, **kwargs) -> None: self.client_key = self._config["fanarttv_key"].get() @staticmethod - def add_default_config(config: confuse.ConfigView): + def add_default_config(config: confuse.ConfigView) -> None: config.add({"fanarttv_key": None}) config["fanarttv_key"].redact = True diff --git a/beetsplug/filefilter.py b/beetsplug/filefilter.py index bfe58b26a5..6ce151a9af 100644 --- a/beetsplug/filefilter.py +++ b/beetsplug/filefilter.py @@ -58,7 +58,7 @@ def import_task_created_event( # If not filtered, return the original task unchanged. return [task] - def file_filter(self, full_path): + def file_filter(self, full_path: bytes) -> bool: """Checks if the configured regular expressions allow the import of the file given in full_path. """ diff --git a/beetsplug/fish.py b/beetsplug/fish.py index ee10f5def4..21b34243e8 100644 --- a/beetsplug/fish.py +++ b/beetsplug/fish.py @@ -11,13 +11,15 @@ import os from operator import attrgetter -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Any, Protocol from beets import library, plugins, ui from beets.plugins import BeetsPlugin from beets.ui import commands if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + from beets.library import Library @@ -65,7 +67,7 @@ class FishCLIOpts(Protocol): class FishPlugin(BeetsPlugin): - def commands(self): + def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand("fish", help="generate Fish shell tab completions") cmd.func = self.run cmd.parser.add_option( @@ -136,25 +138,25 @@ def run(self, lib: Library, opts: FishCLIOpts, args: list[str]) -> None: fish_file.write(totstring) -def _escape(name): +def _escape(name: str) -> str: # Escape ? in fish if name == "?": name = f"\\{name}" return name -def get_cmds_list(cmds_names): +def get_cmds_list(cmds_names: Iterable[str]) -> str: # Make a list of all Beets core & plugin commands return f"set CMDS {' '.join(cmds_names)}\n\n" -def get_standard_fields(fields): +def get_standard_fields(fields: Iterable[str]) -> str: # Make a list of album/track fields and append with ':' fields = (f"{field}:" for field in fields) return f"set FIELDS {' '.join(fields)}\n\n" -def get_extravalues(lib, extravalues): +def get_extravalues(lib: Library, extravalues: Sequence[str]) -> str: # Make a list of all values from an album/track field. # 'beet ls albumartist: ' yields completions for ABBA, Beatles, etc. word = "" @@ -165,7 +167,9 @@ def get_extravalues(lib, extravalues): return word -def get_set_of_values_for_field(lib, fields): +def get_set_of_values_for_field( + lib: Library, fields: Sequence[str] +) -> dict[str, set[Any]]: # Get unique values from a specified album/track field fields_dict = {} for each in fields: @@ -176,7 +180,7 @@ def get_set_of_values_for_field(lib, fields): return fields_dict -def get_basic_beet_options(): +def get_basic_beet_options() -> str: return ( BL_NEED2.format("-l format-item", "-f -d 'print with custom format'") + BL_NEED2.format("-l format-album", "-f -d 'print with custom format'") @@ -198,7 +202,11 @@ def get_basic_beet_options(): ) -def get_subcommands(cmd_name_and_help, nobasicfields, extravalues): +def get_subcommands( + cmd_name_and_help: Iterable[tuple[str, str]], + nobasicfields: bool, + extravalues: Iterable[str], +) -> str: # Formatting for Fish to complete our fields/values word = "" for cmdname, cmdhelp in cmd_name_and_help: @@ -226,7 +234,7 @@ def get_subcommands(cmd_name_and_help, nobasicfields, extravalues): return word -def get_all_commands(beetcmds): +def get_all_commands(beetcmds: Sequence[ui.Subcommand]) -> str: # Formatting for Fish to complete command options word = "" for cmd in beetcmds: @@ -275,12 +283,12 @@ def get_all_commands(beetcmds): return word -def clean_whitespace(word): +def clean_whitespace(word: str) -> str: # Remove excess whitespace and tabs in a string return " ".join(word.split()) -def wrap(word): +def wrap(word: str) -> str: # Need " or ' around strings but watch out if they're in the string sptoken = '"' if '"' in word and ("'") in word: diff --git a/beetsplug/freedesktop.py b/beetsplug/freedesktop.py index 816e03c189..367ffeeafd 100644 --- a/beetsplug/freedesktop.py +++ b/beetsplug/freedesktop.py @@ -14,7 +14,7 @@ class FreedesktopPlugin(BeetsPlugin): - def commands(self): + def commands(self) -> list[ui.Subcommand]: deprecated = ui.Subcommand( "freedesktop", help="Print a message to redirect to thumbnails --dolphin", diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index ebedecc404..b0aa7cb08c 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -12,7 +12,13 @@ from beets.util import displayable_path if TYPE_CHECKING: + from collections.abc import Hashable, Iterable + from beets.importer import ImportSession, ImportTask + from beets.library import Item + from beets.logging import BeetsLogger as Logger + + from ._typing import JSONDict # Filename field extraction patterns. @@ -33,12 +39,12 @@ BAD_TITLE_PATTERNS = [r"^$"] -def equal(seq): +def equal(seq: Iterable[Hashable]) -> bool: """Determine whether a sequence holds identical elements.""" return len(set(seq)) <= 1 -def equal_fields(matchdict, field): +def equal_fields(matchdict: dict[Item, JSONDict], field: str) -> bool: """Do all items in `matchdict`, whose values are dictionaries, have the same value for `field`? (If they do, the field is probably not the title.) @@ -46,7 +52,9 @@ def equal_fields(matchdict, field): return equal(m[field] for m in matchdict.values()) -def all_matches(names, pattern): +def all_matches( + names: dict[Item, str], pattern: str +) -> dict[Item, JSONDict] | None: """If all the filenames in the item/filename mapping match the pattern, return a dictionary mapping the items to dictionaries giving the value for each named subpattern in the match. Otherwise, @@ -65,7 +73,7 @@ def all_matches(names, pattern): return matches -def bad_title(title): +def bad_title(title: str) -> bool: """Determine whether a given title is "bad" (empty or otherwise meaningless) and in need of replacement. """ @@ -75,7 +83,7 @@ def bad_title(title): return False -def apply_matches(d, log): +def apply_matches(d: dict[Item, JSONDict], log: Logger) -> None: """Given a mapping from items to field dicts, apply the fields to the objects. """ diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index 5af35a9a87..2eb7527aeb 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -1,11 +1,17 @@ """Provides a fuzzy matching query.""" +from __future__ import annotations + import difflib +from typing import TYPE_CHECKING from beets import config from beets.dbcore.query import StringFieldQuery from beets.plugins import BeetsPlugin +if TYPE_CHECKING: + from ._typing import JSONDict + class FuzzyQuery(StringFieldQuery[str]): def __init__(self, field_name: str, pattern: str, *_) -> None: @@ -39,6 +45,6 @@ def __init__(self) -> None: super().__init__() self.config.add({"prefix": "~", "threshold": 0.7}) - def queries(self): + def queries(self) -> JSONDict: prefix = self.config["prefix"].as_str() return {prefix: FuzzyQuery} diff --git a/beetsplug/hook.py b/beetsplug/hook.py index 1fd2fc1be8..cb24a49355 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -33,7 +33,7 @@ def convert_field(self, value: Any, conversion: str | None) -> Any: class HookPlugin(BeetsPlugin): """Allows custom commands to be run when an event is emitted by beets""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add({"hooks": []}) @@ -48,7 +48,7 @@ def __init__(self): self.create_and_register_hook(hook_event, hook_command) - def create_and_register_hook(self, event: EventType, command): + def create_and_register_hook(self, event: EventType, command: str) -> None: def hook_function(**kwargs) -> None: if command is None or len(command) == 0: self._log.error('invalid command "{}"', command) diff --git a/beetsplug/ihate.py b/beetsplug/ihate.py index 68096c78bc..f6655d6d82 100644 --- a/beetsplug/ihate.py +++ b/beetsplug/ihate.py @@ -9,6 +9,8 @@ from beets.plugins import BeetsPlugin if TYPE_CHECKING: + from collections.abc import Iterable + from beets.importer import ImportSession, ImportTask @@ -25,7 +27,9 @@ def __init__(self) -> None: self.config.add({"warn": [], "skip": []}) @classmethod - def do_i_hate_this(cls, task, action_patterns): + def do_i_hate_this( + cls, task: ImportTask, action_patterns: Iterable[str] + ) -> bool: """Process group of patterns (warn or skip) and returns True if task is hated and not whitelisted. """ diff --git a/beetsplug/importadded.py b/beetsplug/importadded.py index 606f27b470..588721201e 100644 --- a/beetsplug/importadded.py +++ b/beetsplug/importadded.py @@ -54,10 +54,10 @@ def check_config( self.config["preserve_mtimes"].get(bool) return None - def reimported_item(self, item): + def reimported_item(self, item: Item) -> bool: return item.id in self.reimported_item_ids - def reimported_album(self, album): + def reimported_album(self, album: Album) -> bool: return album.path in self.replaced_album_paths def record_if_inplace( @@ -93,12 +93,12 @@ def record_reimported( } self.replaced_album_paths = set(task.replaced_albums.keys()) - def write_file_mtime(self, path, mtime): + def write_file_mtime(self, path: str, mtime: float) -> None: """Write the given mtime to the destination path.""" stat = os.stat(util.syspath(path)) os.utime(util.syspath(path), (stat.st_atime, mtime)) - def write_item_mtime(self, item, mtime): + def write_item_mtime(self, item: Item, mtime: float) -> None: """Write the given mtime to an item's `mtime` field and to the mtime of the item's file. """ diff --git a/beetsplug/importfeeds.py b/beetsplug/importfeeds.py index 0131e46640..70cbd960df 100644 --- a/beetsplug/importfeeds.py +++ b/beetsplug/importfeeds.py @@ -22,6 +22,8 @@ ) if TYPE_CHECKING: + from collections.abc import Sequence + from beets.importer import ImportSession from beets.library import Album, Item, Library @@ -29,7 +31,7 @@ M3U_DEFAULT_NAME = "imported.m3u" -def _build_m3u_session_filename(basename): +def _build_m3u_session_filename(basename: str) -> bytes: """Builds unique m3u filename by putting current date between given basename and file ending.""" date = datetime.datetime.now().strftime("%Y%m%d_%Hh%M") @@ -41,7 +43,7 @@ def _build_m3u_session_filename(basename): ) -def _build_m3u_filename(basename): +def _build_m3u_filename(basename: str) -> bytes: """Builds unique m3u filename by appending given basename to current date.""" basename = re.sub(r"[\s,/\\'\"]", "_", basename) @@ -53,7 +55,7 @@ def _build_m3u_filename(basename): ) -def _write_m3u(m3u_path, items_paths): +def _write_m3u(m3u_path: bytes, items_paths: Sequence[bytes]) -> None: """Append relative paths to items into m3u file.""" mkdirall(m3u_path) with open(syspath(m3u_path), "ab") as f: @@ -85,13 +87,15 @@ def __init__(self) -> None: self.register_listener("item_imported", self.item_imported) self.register_listener("import_begin", self.import_begin) - def get_feeds_dir(self): + def get_feeds_dir(self) -> str | bytes: feeds_dir = self.config["dir"].get() if feeds_dir: return os.path.expanduser(bytestring_path(feeds_dir)) return config["directory"].as_filename() - def _record_items(self, lib, basename, items): + def _record_items( + self, lib: Library, basename: str, items: Sequence[Item] + ) -> None: """Records relative paths to the given items for each feed format""" feedsdir = bytestring_path(self.get_feeds_dir()) formats = self.config["formats"].as_str_seq() diff --git a/beetsplug/importsource.py b/beetsplug/importsource.py index 8e8abbdf7b..0adef432f6 100644 --- a/beetsplug/importsource.py +++ b/beetsplug/importsource.py @@ -52,7 +52,7 @@ def prevent_suggest_removal( if "mb_albumid" in item: self.stop_suggestions_for_albums.add(item.mb_albumid) - def import_stage(self, _, task): + def import_stage(self, _, task: ImportTask) -> None: """Event handler for albums import finished.""" for item in task.imported_items(): # During reimports (import --library), we prevent overwriting the diff --git a/beetsplug/info.py b/beetsplug/info.py index 3c9559e72f..a80f4f432a 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Literal, Protocol import mediafile @@ -13,8 +13,14 @@ from beets.util import displayable_path, normpath, syspath if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Iterator, Sequence + from beets.library import Library + from ._typing import JSONDict + + DataEmitter = Callable[[Literal["*"] | list[str]], tuple[JSONDict, Any]] + class InfoCLIOpts(Protocol): album: bool @@ -25,7 +31,9 @@ class InfoCLIOpts(Protocol): summarize: bool | None -def tag_data(lib, args, album=False): +def tag_data( + lib: Library, args: Iterable[str], album: bool = False +) -> Iterator[DataEmitter]: query = [] for arg in args: path = normpath(arg) @@ -39,14 +47,15 @@ def tag_data(lib, args, album=False): yield tag_data_emitter(item.path) -def tag_fields(): +def tag_fields() -> set[str]: fields = set(mediafile.MediaFile.readable_fields()) fields.add("art") return fields -def tag_data_emitter(path): - def emitter(included_keys): +def tag_data_emitter(path: bytes) -> DataEmitter: + def emitter(included_keys: Literal["*"] | list[str]) -> tuple[Any, Any]: + fields: set[str] | list[str] if included_keys == "*": fields = tag_fields() else: @@ -70,13 +79,15 @@ def emitter(included_keys): return emitter -def library_data(lib, args, album=False): +def library_data( + lib: Library, args: Sequence[str], album: bool = False +) -> Iterator[DataEmitter]: for item in lib.albums(args) if album else lib.items(args): yield library_data_emitter(item) -def library_data_emitter(item): - def emitter(included_keys): +def library_data_emitter(item: Item) -> DataEmitter: + def emitter(included_keys: Literal["*"] | list[str]) -> tuple[Any, Any]: data = dict(item.formatted(included_keys=included_keys)) return data, item @@ -84,7 +95,7 @@ def emitter(included_keys): return emitter -def update_summary(summary, tags): +def update_summary(summary: JSONDict, tags: JSONDict) -> JSONDict: for key, value in tags.items(): if key not in summary: summary[key] = value @@ -93,7 +104,9 @@ def update_summary(summary, tags): return summary -def print_data(data, item=None, fmt=None): +def print_data( + data: JSONDict, item: Item | None = None, fmt: str | None = None +) -> None: """Print, with optional formatting, the fields of a single element. If no format string `fmt` is passed, the entries on `data` are printed one @@ -129,7 +142,7 @@ def print_data(data, item=None, fmt=None): ui.print_(f"{field:>{maxwidth}}: {value}") -def print_data_keys(data, item=None): +def print_data_keys(data: JSONDict, item: Item | None = None) -> None: """Print only the keys (field names) for an item.""" path = displayable_path(item.path) if item else None formatted = [] @@ -147,7 +160,7 @@ def print_data_keys(data, item=None): class InfoPlugin(BeetsPlugin): - def commands(self): + def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand("info", help="show file metadata") cmd.func = self.run cmd.parser.add_album_option() diff --git a/beetsplug/inline.py b/beetsplug/inline.py index 4cf99d1428..d823cf2fbb 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -1,24 +1,35 @@ """Allows inline path template customization code in the config file.""" +from __future__ import annotations + import itertools import traceback +from typing import TYPE_CHECKING from beets import config from beets.plugins import BeetsPlugin +if TYPE_CHECKING: + from collections.abc import Callable + + from beets.library import LibModel + + from ._typing import JSONDict + + FUNC_NAME = "__INLINE_FUNC__" class InlineError(Exception): """Raised when a runtime error occurs in an inline expression.""" - def __init__(self, code, exc): + def __init__(self, code: str, exc: BaseException) -> None: super().__init__( f"error in inline path field code:\n{code}\n{type(exc).__name__}: {exc}" ) -def _compile_func(body, args=""): +def _compile_func(body: str, args: str = "") -> Callable[..., Any]: """Given Python code for a function body, return a compiled callable that invokes that code. """ @@ -31,7 +42,7 @@ def _compile_func(body, args=""): class InlinePlugin(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() config.add( @@ -58,7 +69,9 @@ def __init__(self): if func is not None: self.album_template_fields[key] = func - def compile_inline(self, python_code, album, field_name): + def compile_inline( + self, python_code: str, album: bool, field_name: str + ) -> Callable[[LibModel], Any] | None: """Given a Python expression or function body, compile it as a path field function. The returned function takes a single argument, an Item, and returns a Unicode string. If the expression cannot be @@ -82,7 +95,7 @@ def compile_inline(self, python_code, album, field_name): else: is_expr = True - def _dict_for(obj): + def _dict_for(obj: LibModel) -> JSONDict: out = {} for key in obj.keys(computed=False): if key == field_name: @@ -95,7 +108,7 @@ def _dict_for(obj): if is_expr: # For expressions, just evaluate and return the result. - def _expr_func(obj): + def _expr_func(obj: LibModel) -> Any: values = _dict_for(obj) values["db_obj"] = obj try: @@ -107,7 +120,7 @@ def _expr_func(obj): # For function bodies, invoke the function with values as global # variables. - def _func_func(obj): + def _func_func(obj: LibModel) -> Any: old_globals = dict(func.__globals__) func.__globals__.update(_dict_for(obj)) try: diff --git a/beetsplug/ipfs.py b/beetsplug/ipfs.py index bb80d9af34..7500114747 100644 --- a/beetsplug/ipfs.py +++ b/beetsplug/ipfs.py @@ -14,9 +14,11 @@ from beets.plugins import BeetsPlugin if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Sequence - from beets.library import Library + from beets.dbcore import Results + from beets.importer import ImportSession, ImportTask + from beets.library import Album, Library class IPFSCLIOpts(Protocol): @@ -29,14 +31,14 @@ class IPFSCLIOpts(Protocol): class IPFSPlugin(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add({"auto": True, "nocopy": False}) if self.config["auto"]: self.import_stages = [self.auto_add] - def commands(self): + def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand("ipfs", help="interact with ipfs") cmd.parser.add_option( "-a", "--add", dest="add", action="store_true", help="Add to ipfs" @@ -102,7 +104,7 @@ def func(lib: Library, opts: IPFSCLIOpts, args: list[str]) -> None: cmd.func = func return [cmd] - def auto_add(self, session, task): + def auto_add(self, session: ImportSession, task: ImportTask) -> None: if task.is_album: if self.ipfs_add(task.album): task.album.store() @@ -121,7 +123,7 @@ def ipfs_play( with self.remote_lib(lib) as jlib: player._play_command(jlib, play_opts, args) - def ipfs_add(self, album): + def ipfs_add(self, album: Album) -> bool: try: album_dir = album.item_dir() except AttributeError: @@ -167,7 +169,7 @@ def ipfs_add(self, album): return True - def ipfs_get(self, lib, query): + def ipfs_get(self, lib: Library, query: Sequence[str]) -> None: query = query[0] # Check if query is a hash # TODO: generalize to other hashes; probably use a multihash @@ -179,7 +181,7 @@ def ipfs_get(self, lib, query): for album in albums: self.ipfs_get_from_hash(lib, album.ipfs) - def ipfs_get_from_hash(self, lib, _hash): + def ipfs_get_from_hash(self, lib: Library, _hash: str) -> bool | None: try: cmd = "ipfs get".split() cmd.append(_hash) @@ -198,7 +200,7 @@ def ipfs_get_from_hash(self, lib, _hash): shutil.rmtree(_hash) return None - def ipfs_publish(self, lib): + def ipfs_publish(self, lib: Library) -> bool | None: with tempfile.NamedTemporaryFile() as tmp: self.ipfs_added_albums(lib, tmp.name) try: @@ -215,7 +217,7 @@ def ipfs_publish(self, lib): self._log.info("hash of library: {}", output) return None - def ipfs_import(self, lib, args): + def ipfs_import(self, lib: Library, args: Sequence[str]) -> bool | None: _hash = args[0] if len(args) > 1: lib_name = args[1] @@ -254,13 +256,13 @@ def ipfs_import(self, lib, args): added_album.store() return None - def already_added(self, check, jlib): + def already_added(self, check: Album, jlib: Library) -> bool: for jalbum in jlib.albums(): if jalbum.mb_albumid == check.mb_albumid: return True return False - def ipfs_list(self, lib, args): + def ipfs_list(self, lib: Library, args: Sequence[str]) -> None: fmt = config["format_album"].get() try: albums = self.query(lib, args) @@ -271,11 +273,11 @@ def ipfs_list(self, lib, args): for album in albums: ui.print_(format(album, fmt), " : ", album.ipfs.decode()) - def query(self, lib, args): + def query(self, lib: Library, args: str | Sequence[str]) -> Results[Album]: with self.remote_lib(lib) as rlib: return rlib.albums(args) - def _remote_libs_path(self, lib): + def _remote_libs_path(self, lib: Library) -> bytes: lib_root = os.path.dirname(os.fsencode(lib.path)) return os.path.join(lib_root, b"remotes") @@ -293,7 +295,7 @@ def remote_lib(self, lib: Library) -> Iterator[Library]: finally: remote_lib._close() - def ipfs_added_albums(self, rlib, tmpname): + def ipfs_added_albums(self, rlib: Library, tmpname: str) -> Library: """Returns a new library with only albums/items added to ipfs""" tmplib = library.Library( tmpname, directory="/ipfs/", set_music_dir=False @@ -307,7 +309,7 @@ def ipfs_added_albums(self, rlib, tmpname): pass return tmplib - def create_new_album(self, album, tmplib): + def create_new_album(self, album: Album, tmplib: Library) -> bool | None: items = [] for item in album.items(): try: diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py index bf4bad2bd0..9b417668fb 100644 --- a/beetsplug/keyfinder.py +++ b/beetsplug/keyfinder.py @@ -11,19 +11,21 @@ if TYPE_CHECKING: import optparse + from collections.abc import Sequence - from beets.library import Library + from beets.importer import ImportSession, ImportTask + from beets.library import Item, Library class KeyFinderPlugin(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add({"bin": "KeyFinder", "auto": True, "overwrite": False}) if self.config["auto"].get(bool): self.import_stages = [self.imported] - def commands(self): + def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand( "keyfinder", help="detect and add initial key from audio" ) @@ -35,10 +37,10 @@ def command( ) -> None: self.find_key(lib.items(args), write=ui.should_write()) - def imported(self, session, task): + def imported(self, session: ImportSession, task: ImportTask) -> None: self.find_key(task.imported_items()) - def find_key(self, items, write=False): + def find_key(self, items: Sequence[Item], write: bool = False) -> None: overwrite = self.config["overwrite"].get(bool) command = [self.config["bin"].as_str()] # The KeyFinder GUI program needs the -f flag before the path. diff --git a/beetsplug/kodiupdate.py b/beetsplug/kodiupdate.py index 0939544fba..d262d55464 100644 --- a/beetsplug/kodiupdate.py +++ b/beetsplug/kodiupdate.py @@ -21,7 +21,9 @@ from beets.library import LibModel, Library -def update_kodi(host, port, user, password): +def update_kodi( + host: str, port: int, user: str, password: str +) -> requests.Response: """Sends request to the Kodi api to start a library refresh.""" url = f"http://{host}:{port}/jsonrpc" diff --git a/beetsplug/lastgenre/client.py b/beetsplug/lastgenre/client.py index edcfe1224f..92061676e0 100644 --- a/beetsplug/lastgenre/client.py +++ b/beetsplug/lastgenre/client.py @@ -54,7 +54,7 @@ def __init__( min_weight: int, ignore_patterns: IgnorePatternsByArtist, alias_patterns: list[AliasPatternWithReplacement], - ): + ) -> None: """Initialize the client. The min_weight parameter filters tags by their minimum weight. diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py index d996fe33e6..e5b1df68e9 100644 --- a/beetsplug/lastimport.py +++ b/beetsplug/lastimport.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple import pylast from pylast import TopItem, _extract, _number @@ -15,6 +15,7 @@ import optparse from beets.library import Library + from beets.logging import BeetsLogger as Logger from ._utils.playcount import Track @@ -22,7 +23,7 @@ class LastImportPlugin(plugins.BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() config["lastfm"].add({"user": "", "api_key": plugins.LASTFM_KEY}) config["lastfm"]["user"].redact = True @@ -30,7 +31,7 @@ def __init__(self): self.config.add({"per_page": 500, "retry_limit": 3}) self.item_types = {"lastfm_play_count": types.INTEGER} - def commands(self): + def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand("lastimport", help="import last.fm play-count") def func(lib: Library, opts: optparse.Values, args: list[str]) -> None: @@ -47,12 +48,18 @@ class CustomUser(pylast.User): tracks. """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) def _get_things( - self, method, thing, thing_type, params=None, cacheable=True - ): + self, + method: str, + thing: str, + thing_type: type[pylast.Track | pylast.Album], + params: type[pylast._Opus] | None = None, + cacheable: bool = True, + stream: bool = False, + ) -> tuple[list[OurTopItem], int]: """Returns a list of the most played thing_types by this thing, in a tuple with the total number of pages of results. Includes an MBID, if found. @@ -76,8 +83,12 @@ def _get_things( return seq, total_pages def get_top_tracks_by_page( - self, period=pylast.PERIOD_OVERALL, limit=None, page=1, cacheable=True - ): + self, + period: str = pylast.PERIOD_OVERALL, + limit: int | None = None, + page: int = 1, + cacheable: bool = True, + ) -> tuple[list[OurTopItem], int]: """Returns the top tracks played by a user, in a tuple with the total number of pages of results. * period: The period of time. Possible values: @@ -100,7 +111,7 @@ def get_top_tracks_by_page( ) -def import_lastfm(lib, log): +def import_lastfm(lib: Library, log: Logger) -> None: user = config["lastfm"]["user"].as_str() per_page = config["lastimport"]["per_page"].get(int) @@ -156,7 +167,7 @@ def import_lastfm(lib, log): log.info("{} play-counts imported", found_total) -def fetch_tracks(user, page, limit) -> tuple[list[Track], int]: +def fetch_tracks(user: str, page: int, limit: int) -> tuple[list[Track], int]: network = pylast.LastFMNetwork(api_key=config["lastfm"]["api_key"].get(str)) user_obj = CustomUser(user, network) results, total_pages = user_obj.get_top_tracks_by_page( diff --git a/beetsplug/limit.py b/beetsplug/limit.py index 06ee1ce74a..f3af131982 100644 --- a/beetsplug/limit.py +++ b/beetsplug/limit.py @@ -22,6 +22,8 @@ from beets.library import LibModel, Library + from ._typing import JSONDict + class LsLimitCLIOpts(Protocol): album: bool @@ -69,11 +71,11 @@ def lslimit(lib: Library, opts: LsLimitCLIOpts, args: list[str]) -> None: class LimitPlugin(BeetsPlugin): """Query limit functionality via command and query prefix.""" - def commands(self): + def commands(self) -> list[Subcommand]: """Expose `lslimit` subcommand.""" return [lslimit_cmd] - def queries(self): + def queries(self) -> JSONDict: class HeadQuery(FieldQuery): """This inner class pattern allows the query to track state.""" @@ -86,7 +88,7 @@ def __init__(self, *args, **kwargs) -> None: self.fast = False @classmethod - def value_match(cls, pattern, value): + def value_match(cls, pattern: str, value: str) -> bool: if cls.N is None: cls.N = int(pattern) if cls.N < 0: diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 5fd6d4ccde..928686ff95 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -12,7 +12,7 @@ from html import unescape from itertools import filterfalse, groupby from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, NamedTuple, Protocol +from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol from urllib.parse import quote, quote_plus, urlencode, urlparse import requests @@ -71,7 +71,7 @@ class GeniusHTTPError(requests.exceptions.HTTPError): # Utilities. -def search_pairs(item): +def search_pairs(item: Item) -> Iterable[tuple[str, list[str]]]: """Yield a pairs of artists and titles to search for. The first item in the pair is the name of the artist, the second @@ -86,7 +86,9 @@ def search_pairs(item): The method also tries to split multiple titles separated with `/`. """ - def generate_alternatives(string, patterns): + def generate_alternatives( + string: str, patterns: Iterable[str] + ) -> list[str]: """Generate string alternatives by extracting first matching group for each given pattern. """ @@ -210,13 +212,17 @@ def get_text( r.encoding = None return r.text - def get_json(self, url: str, params: JSONDict | None = None, **kwargs): + def get_json( + self, url: str, params: JSONDict | None = None, **kwargs + ) -> Any: """Return JSON data from the given URL.""" url = self.format_url(url, params) self.debug("Fetching JSON from {}", url) return super().get_json(url, **kwargs) - def post_json(self, url: str, params: JSONDict | None = None, **kwargs): + def post_json( + self, url: str, params: JSONDict | None = None, **kwargs + ) -> Any: """Send POST request and return JSON response.""" url = self.format_url(url, params) self.debug("Posting JSON to {}", url) @@ -648,7 +654,7 @@ class Tekstowo(SearchBackend): BASE_URL = "https://www.tekstowo.pl" SEARCH_URL = f"{BASE_URL}/szukaj,{{}}.html" - def build_url(self, artist, title): + def build_url(self, artist: str, title: str) -> str: artistitle = f"{artist.title()} {title.title()}" return self.SEARCH_URL.format(quote_plus(unidecode(artistitle))) @@ -1058,7 +1064,7 @@ def translator(self) -> Translator | None: return Translator.from_config(self._log, **config.flatten()) return None - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add( { @@ -1101,7 +1107,7 @@ def __init__(self): if self.config["auto"]: self.import_stages = [self.imported] - def commands(self): + def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand("lyrics", help="fetch song lyrics") cmd.parser.add_option( "-p", diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index e7dc1f7069..a86b5601f1 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -170,7 +170,7 @@ def collection(self) -> MBCollection: return MBCollection(collection, self.mb_api) - def commands(self): + def commands(self) -> list[Subcommand]: mbupdate = Subcommand("mbupdate", help="Update MusicBrainz collection") mbupdate.parser.add_option( "-r", diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index ed0c03b855..e0a55b8e60 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any import mediafile -from typing_extensions import override +from typing_extensions import Self, override from beets import config from beets.autotag import AlbumInfo, Source, assign_items, distance @@ -26,6 +26,7 @@ from beets.autotag import AlbumMatch, Distance from beets.library import Item + from ._typing import JSONDict from ._utils.musicbrainz import ( Release, ReleaseRelation, @@ -188,7 +189,7 @@ def _wanted_pseudo_release_id( def _replace_artist_with_alias( self, raw_pseudo_release: Release, pseudo_release: AlbumInfo - ): + ) -> None: """Use the pseudo-release's language to search for artist alias if the user hasn't configured import languages.""" @@ -221,7 +222,7 @@ def _replace_artist_with_alias( def _add_custom_tags( self, official_release: AlbumInfo, pseudo_release: AlbumInfo - ): + ) -> None: for tag_key, pseudo_key in ( self.config["album_custom_tags"].get().items() ): @@ -279,7 +280,7 @@ def __init__( self[k] = v @cached_property - def raw_data(self): + def raw_data(self) -> JSONDict: # Info.raw_data does self.__class__(**self.copy()) which fails for # PseudoAlbumInfo since __init__ requires pseudo_release and # official_release. Construct a plain AlbumInfo instead. @@ -310,10 +311,10 @@ def _compute_distance(self, items: Sequence[Item]) -> Distance: len(items) - len(mapping), ) - def use_pseudo_as_ref(self): + def use_pseudo_as_ref(self) -> None: self.__dict__["_pseudo_source"] = True - def use_official_as_ref(self): + def use_official_as_ref(self) -> None: self.__dict__["_pseudo_source"] = False def __getattr__(self, attr: str) -> Any: @@ -322,7 +323,7 @@ def __getattr__(self, attr: str) -> Any: return super().__getattr__(attr) return self.__dict__["_official_release"].__getattr__(attr) - def __deepcopy__(self, memo): + def __deepcopy__(self, memo: dict[int, Any]) -> Self: cls = self.__class__ result = cls.__new__(cls) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 0060c0460c..9e366a0bde 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -10,6 +10,8 @@ from beets.plugins import BeetsPlugin, apply_item_changes if TYPE_CHECKING: + from collections.abc import Sequence + from beets.library import Library @@ -20,10 +22,10 @@ class MBSyncCLIOpts(Protocol): class MBSyncPlugin(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() - def commands(self): + def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand("mbsync", help="update metadata from musicbrainz") cmd.parser.add_option( "-p", @@ -66,7 +68,14 @@ def func(self, lib: Library, opts: MBSyncCLIOpts, args: list[str]) -> None: self.singletons(lib, args, move, pretend, write) self.albums(lib, args, move, pretend, write) - def singletons(self, lib, query, move, pretend, write): + def singletons( + self, + lib: Library, + query: Sequence[str], + move: bool, + pretend: bool, + write: bool, + ) -> None: """Retrieve and apply info from the autotagger for items matched by query. """ @@ -94,7 +103,14 @@ def singletons(self, lib, query, move, pretend, write): ) apply_item_changes(lib, item, move, pretend, write) - def albums(self, lib, query, move, pretend, write): + def albums( + self, + lib: Library, + query: Sequence[str], + move: bool, + pretend: bool, + write: bool, + ) -> None: """Retrieve and apply info from the autotagger for albums matched by query and their items. """ diff --git a/beetsplug/metasync/__init__.py b/beetsplug/metasync/__init__.py index 483e050814..6663b15dbd 100644 --- a/beetsplug/metasync/__init__.py +++ b/beetsplug/metasync/__init__.py @@ -12,8 +12,11 @@ from beets.plugins import BeetsPlugin if TYPE_CHECKING: + from confuse import ConfigView + from beets.dbcore import types - from beets.library import Library + from beets.library import Item, Library + from beets.logging import BeetsLogger as Logger METASYNC_MODULE = "beetsplug.metasync" @@ -29,16 +32,16 @@ class MetaSyncCLIOpts(Protocol): class MetaSource(metaclass=ABCMeta): item_types: ClassVar[dict[str, types.Type]] - def __init__(self, config, log): + def __init__(self, config: ConfigView, log: Logger) -> None: self.config = config self._log = log @abstractmethod - def sync_from_source(self, item): + def sync_from_source(self, item: Item) -> None: pass -def load_meta_sources(): +def load_meta_sources() -> dict[str, type[MetaSource]]: """Returns a dictionary of all the MetaSources E.g., {'itunes': Itunes} with isinstance(Itunes, MetaSource) true """ @@ -54,7 +57,7 @@ def load_meta_sources(): META_SOURCES = load_meta_sources() -def load_item_types(): +def load_item_types() -> dict[str, types.Type]: """Returns a dictionary containing the item_types of all the MetaSources""" item_types = {} for meta_source in META_SOURCES.values(): @@ -65,10 +68,10 @@ def load_item_types(): class MetaSyncPlugin(BeetsPlugin): item_types = load_item_types() - def __init__(self): + def __init__(self) -> None: super().__init__() - def commands(self): + def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand( "metasync", help="update metadata from music player libraries" ) diff --git a/beetsplug/metasync/amarok.py b/beetsplug/metasync/amarok.py index 2bd64dbbf5..f6036d602e 100644 --- a/beetsplug/metasync/amarok.py +++ b/beetsplug/metasync/amarok.py @@ -1,17 +1,27 @@ """Synchronize information from amarok's library via dbus""" +from __future__ import annotations + from datetime import datetime from os.path import basename from time import mktime -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar from xml.sax.saxutils import quoteattr from beets.dbcore import types from beets.util import displayable_path from beetsplug.metasync import MetaSource +if TYPE_CHECKING: + from types import ModuleType + + from confuse import ConfigView + + from beets.library import Item + from beets.logging import BeetsLogger as Logger + -def import_dbus(): +def import_dbus() -> ModuleType | None: try: return __import__("dbus") except ImportError: @@ -38,7 +48,7 @@ class Amarok(MetaSource): """ - def __init__(self, config, log): + def __init__(self, config: ConfigView, log: Logger) -> None: super().__init__(config, log) if not dbus: @@ -48,7 +58,7 @@ def __init__(self, config, log): "org.kde.amarok", "/Collection" ) - def sync_from_source(self, item): + def sync_from_source(self, item: Item) -> None: path = displayable_path(item.path) # amarok unfortunately doesn't allow searching for the full path, only diff --git a/beetsplug/metasync/itunes.py b/beetsplug/metasync/itunes.py index 7238c1d666..c4165912fa 100644 --- a/beetsplug/metasync/itunes.py +++ b/beetsplug/metasync/itunes.py @@ -1,12 +1,14 @@ """Synchronize information from iTunes's library""" +from __future__ import annotations + import os import plistlib import shutil import tempfile from contextlib import contextmanager from time import mktime -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar from urllib.parse import unquote, urlparse from confuse import ConfigValueError @@ -16,9 +18,17 @@ from beets.util import bytestring_path, syspath from beetsplug.metasync import MetaSource +if TYPE_CHECKING: + from collections.abc import Iterator + + from confuse import ConfigView + + from beets.library import Item + from beets.logging import BeetsLogger as Logger + @contextmanager -def create_temporary_copy(path): +def create_temporary_copy(path: util.PathLike) -> Iterator[bytes]: temp_dir = bytestring_path(tempfile.mkdtemp()) temp_path = os.path.join(temp_dir, b"temp_itunes_lib") shutil.copyfile(syspath(path), syspath(temp_path)) @@ -28,7 +38,7 @@ def create_temporary_copy(path): shutil.rmtree(syspath(temp_dir)) -def _norm_itunes_path(path): +def _norm_itunes_path(path: bytes) -> bytes: # Itunes prepends the location with 'file://' on posix systems, # and with 'file://localhost/' on Windows systems. # The actual path to the file is always saved as posix form @@ -54,7 +64,7 @@ class Itunes(MetaSource): "itunes_dateadded": types.DATE, } - def __init__(self, config, log): + def __init__(self, config: ConfigView, log: Logger) -> None: super().__init__(config, log) config.add({"itunes": {"library": "~/Music/iTunes/iTunes Library.xml"}}) @@ -87,7 +97,7 @@ def __init__(self, config, log): if "Location" in track } - def sync_from_source(self, item): + def sync_from_source(self, item: Item) -> None: result = self.collection.get(util.bytestring_path(item.path).lower()) if not result: diff --git a/beetsplug/missing.py b/beetsplug/missing.py index 6f8d5555af..edb6e60b25 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -19,6 +19,7 @@ import optparse from collections.abc import Iterator + from beets.autotag import AlbumInfo, TrackInfo from beets.library import Library # Valid MusicBrainz release types for filtering release groups @@ -43,12 +44,14 @@ MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$" -def _missing_count(album): +def _missing_count(album: Album) -> bool | int: """Return number of missing items in `album`.""" return (album.albumtotal or 0) - len(album.items()) -def _item(track_info, album_info, album_id): +def _item( + track_info: TrackInfo, album_info: AlbumInfo, album_id: int | None +) -> Item: """Build and return `item` from `track_info` and `album info` objects. `item` is missing what fields cannot be obtained from MusicBrainz alone (encoder, rg_track_gain, rg_track_peak, @@ -101,7 +104,7 @@ class MissingPlugin(MusicBrainzAPIMixin, BeetsPlugin): album_types: ClassVar[dict[str, types.Type]] = {"missing": types.INTEGER} - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add( @@ -151,7 +154,7 @@ def __init__(self): ) self._command.parser.add_format_option() - def commands(self): + def commands(self) -> list[Subcommand]: def _miss(lib: Library, opts: optparse.Values, args: list[str]) -> None: self.config.set_args(opts) albms = self.config["album"].get() @@ -162,7 +165,7 @@ def _miss(lib: Library, opts: optparse.Values, args: list[str]) -> None: self._command.func = _miss return [self._command] - def _missing_tracks(self, lib, query): + def _missing_tracks(self, lib: Library, query: list[str]) -> None: """Print a listing of tracks missing from each album in the library matching query. """ diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py index 28466161a9..238706eee6 100644 --- a/beetsplug/mpdupdate.py +++ b/beetsplug/mpdupdate.py @@ -26,7 +26,7 @@ class BufferedSocket: """Socket abstraction that allows reading by line.""" - def __init__(self, host, port, sep=b"\n") -> None: + def __init__(self, host: str, port: int, sep: bytes = b"\n") -> None: if host[0] in ["/", "~"]: self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.connect(os.path.expanduser(host)) @@ -36,7 +36,7 @@ def __init__(self, host, port, sep=b"\n") -> None: self.buf = b"" self.sep = sep - def readline(self): + def readline(self) -> bytes: while self.sep not in self.buf: data = self.sock.recv(1024) if not data: @@ -47,10 +47,10 @@ def readline(self): return res + self.sep return b"" - def send(self, data): + def send(self, data: bytes) -> None: self.sock.send(data) - def close(self): + def close(self) -> None: self.sock.close() @@ -84,7 +84,12 @@ def update(self, lib: Library) -> None: config["mpd"]["password"].as_str(), ) - def update_mpd(self, host="localhost", port=6600, password=None) -> None: + def update_mpd( + self, + host: str = "localhost", + port: int = 6600, + password: str | None = None, + ) -> None: """Sends the "update" command to the MPD server indicated, possibly authenticating with a password first. """ diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py index c59d8c55f6..9e3879915d 100644 --- a/beetsplug/parentwork.py +++ b/beetsplug/parentwork.py @@ -16,12 +16,15 @@ if TYPE_CHECKING: import optparse - from beets.library import Library + from beets.importer import ImportSession, ImportTask + from beets.library import Item, Library from beetsplug._utils.musicbrainz import Work + from ._typing import JSONDict + class ParentWorkPlugin(MusicBrainzAPIMixin, BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add({"auto": False, "force": False}) @@ -29,7 +32,7 @@ def __init__(self): if self.config["auto"]: self.import_stages = [self.imported] - def commands(self): + def commands(self) -> list[ui.Subcommand]: def func(lib: Library, opts: optparse.Values, args: list[str]) -> None: self.config.set_args(opts) force_parent = self.config["force"].get(bool) @@ -58,7 +61,7 @@ def func(lib: Library, opts: optparse.Values, args: list[str]) -> None: command.func = func return [command] - def imported(self, session, task): + def imported(self, session: ImportSession, task: ImportTask) -> None: """Import hook for fetching parent works automatically.""" force_parent = self.config["force"].get(bool) @@ -66,7 +69,7 @@ def imported(self, session, task): self.find_work(item, force_parent, verbose=False) item.store() - def get_info(self, item, work_info): + def get_info(self, item: Item, work_info: Work) -> JSONDict: """Given the parent work info dict, fetch parent_composer, parent_composer_sort, parentwork, parentwork_disambig, mb_workid and composer_ids. @@ -109,7 +112,7 @@ def get_info(self, item, work_info): return parentwork_info - def find_work(self, item, force, verbose): + def find_work(self, item: Item, force: bool, verbose: bool) -> bool | None: """Finds the parent work of a recording and populates the tags accordingly. diff --git a/beetsplug/permissions.py b/beetsplug/permissions.py index ac0df521c8..32d78cdf60 100644 --- a/beetsplug/permissions.py +++ b/beetsplug/permissions.py @@ -17,10 +17,13 @@ from beets.util import ancestry, displayable_path, syspath if TYPE_CHECKING: + from collections.abc import Iterable + from beets.library import Album, Item, Library + from beets.logging import BeetsLogger as Logger -def convert_perm(perm): +def convert_perm(perm: str | int) -> int: """Convert a string to an integer, interpreting the text as octal. Or, if `perm` is an integer, reinterpret it as an octal number that has been "misinterpreted" as decimal. @@ -30,14 +33,14 @@ def convert_perm(perm): return int(perm, 8) -def check_permissions(path, permission): +def check_permissions(path: bytes, permission: int) -> bool: """Check whether the file's permissions equal the given vector. Return a boolean. """ return oct(stat.S_IMODE(os.stat(syspath(path)).st_mode)) == oct(permission) -def assert_permissions(path, permission, log): +def assert_permissions(path: bytes, permission: int, log: Logger) -> None: """Check whether the file's permissions are as expected, otherwise, log a warning message. Return a boolean indicating the match, like `check_permissions`. @@ -51,7 +54,7 @@ def assert_permissions(path, permission, log): ) -def dirs_in_library(library, item): +def dirs_in_library(library: bytes, path: bytes) -> list[bytes]: """Creates a list of ancestor directories in the beets library path.""" return [ ancestor for ancestor in ancestry(item) if ancestor.startswith(library) @@ -87,7 +90,9 @@ def fix_art(self, album: Album) -> None: if album.artpath: self.set_permissions(files=[album.artpath]) - def set_permissions(self, files=[], dirs=[]): + def set_permissions( + self, files: Iterable[bytes] = [], dirs: Iterable[bytes] = [] + ) -> None: # Get the configured permissions. The user can specify this either a # string (in YAML quotes) or, for convenience, as an integer so the # quotes can be omitted. In the latter case, we need to reinterpret the diff --git a/beetsplug/play.py b/beetsplug/play.py index c6ffa8756f..70d403e086 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -16,10 +16,11 @@ from beets.util.color import colorize if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Iterable, Sequence from beets.importer import ImportSession, ImportTask from beets.library import LibModel, Library + from beets.logging import BeetsLogger as Logger # Indicate where arguments should be inserted into the command string. @@ -41,14 +42,14 @@ class PlayCLIOpts(Protocol): def play( - command_str, - selection, - paths, - open_args, - log, - item_type="track", - keep_open=False, -): + command_str: str, + selection: Sequence[LibModel], + paths: Sequence[bytes], + open_args: Sequence[bytes], + log: Logger, + item_type: str = "track", + keep_open: bool = False, +) -> None: """Play items in paths with command_str and optional arguments. If keep_open, return to beets, otherwise exit once command runs. """ @@ -87,7 +88,7 @@ def __init__(self) -> None: "before_choose_candidate", self.before_choose_candidate_listener ) - def commands(self): + def commands(self) -> list[Subcommand]: play_command = Subcommand( "play", help="send music to a player as a playlist" ) @@ -179,7 +180,7 @@ def _play_command( ): play(command_str, selection, paths, open_args, self._log, item_type) - def _command_str(self, args=None): + def _command_str(self, args: list[str] | None = None) -> str: """Create a command string from the config command and optional args.""" command_str = config["play"]["command"].get() if not command_str: @@ -192,15 +193,19 @@ def _command_str(self, args=None): # Don't include the marker in the command. return command_str.replace(f" {ARGS_MARKER}", "") - def _playlist_or_paths(self, paths): + def _playlist_or_paths(self, paths: list[bytes]) -> list[bytes]: """Return either the raw paths of items or a playlist of the items.""" if config["play"]["raw"]: return paths return [self._create_tmp_playlist(paths)] def _exceeds_threshold( - self, selection, command_str, open_args, item_type="track" - ): + self, + selection: Sequence[LibModel], + command_str: str, + open_args: Sequence[bytes], + item_type: str = "track", + ) -> bool: """Prompt user whether to abort if playlist exceeds threshold. If True, cancel playback. If False, execute play command. """ @@ -223,7 +228,7 @@ def _exceeds_threshold( return False - def _create_tmp_playlist(self, paths_list): + def _create_tmp_playlist(self, paths_list: Iterable[bytes]) -> bytes: """Create a temporary .m3u file. Return the filename.""" utf8_bom = config["play"]["bom"].get(bool) filename = get_temp_filename(__name__, suffix=".m3u") @@ -242,7 +247,7 @@ def before_choose_candidate_listener( """Append a "Play" choice to the interactive importer prompt.""" return [PromptChoice("y", "plaY", self.importer_play)] - def importer_play(self, session, task): + def importer_play(self, session: ImportSession, task: ImportTask) -> None: """Get items from current import task and send to play function.""" selection = task.items paths = [item.path for item in selection] diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index 1c57419d7b..30ccd7f131 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -12,7 +12,7 @@ from beets.util import path_as_posix if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Iterator, Sequence from beets.dbcore.query import FieldQueryType from beets.library import Item, Library @@ -140,7 +140,7 @@ def cli_exit(self, lib: Library) -> None: except beets.util.FilesystemError: self._log.error("Failed to update playlist: {}", playlist) - def find_playlists(self): + def find_playlists(self) -> Iterator[str]: """Find M3U playlists in the playlist directory.""" playlist_dir = beets.util.syspath(self.playlist_dir) try: @@ -155,7 +155,7 @@ def find_playlists(self): if is_m3u_file(filename): yield os.path.join(self.playlist_dir, filename) - def update_playlist(self, filename, base_dir): + def update_playlist(self, filename: str, base_dir: bytes) -> None: """Find M3U playlists in the specified directory.""" changes = 0 deletions = 0 diff --git a/beetsplug/plexupdate.py b/beetsplug/plexupdate.py index d50742d49c..8b1feca50e 100644 --- a/beetsplug/plexupdate.py +++ b/beetsplug/plexupdate.py @@ -24,8 +24,13 @@ def get_music_section( - host, port, token, library_name, secure, ignore_cert_errors -): + host: str, + port: int, + token: str, + library_name: str, + secure: bool, + ignore_cert_errors: bool, +) -> str | None: """Getting the section key for the music library in Plex.""" api_endpoint = append_token("library/sections", token) url = urljoin(f"{get_protocol(secure)}://{host}:{port}", api_endpoint) @@ -41,7 +46,14 @@ def get_music_section( return None -def update_plex(host, port, token, library_name, secure, ignore_cert_errors): +def update_plex( + host: str, + port: int, + token: str, + library_name: str, + secure: bool, + ignore_cert_errors: bool, +) -> requests.Response: """Ignore certificate errors if configured to.""" if ignore_cert_errors: import urllib3 @@ -61,14 +73,14 @@ def update_plex(host, port, token, library_name, secure, ignore_cert_errors): return requests.get(url, verify=not ignore_cert_errors, timeout=10) -def append_token(url, token): +def append_token(url: str, token: str) -> str: """Appends the Plex Home token to the api call if required.""" if token: url += f"?{urlencode({'X-Plex-Token': token})}" return url -def get_protocol(secure): +def get_protocol(secure: bool) -> str: if secure: return "https" return "http" diff --git a/beetsplug/random.py b/beetsplug/random.py index cee2cf98cc..fb27d60683 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -22,7 +22,7 @@ class RandomCLIOpts(Protocol): time: float | None -def random_func(lib: Library, opts: RandomCLIOpts, args: list[str]): +def random_func(lib: Library, opts: RandomCLIOpts, args: list[str]) -> None: """Select some random items or albums and print the results.""" # Fetch all the objects matching the query into a list. objs = lib.albums(args) if opts.album else lib.items(args) @@ -73,7 +73,7 @@ def random_func(lib: Library, opts: RandomCLIOpts, args: list[str]): class Random(BeetsPlugin): - def commands(self): + def commands(self) -> list[Subcommand]: return [random_cmd] diff --git a/beetsplug/replace.py b/beetsplug/replace.py index c2aa2bf18f..02de6fbe41 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -11,18 +11,23 @@ from beets.plugins import BeetsPlugin if TYPE_CHECKING: + import optparse + from collections.abc import Sequence + from beets.library import Item, Library class ReplacePlugin(BeetsPlugin): - def commands(self): + def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand( "replace", help="replace audio file while keeping tags" ) cmd.func = self.run return [cmd] - def run(self, lib: Library, _opts, args: list[str]) -> None: + def run( + self, lib: Library, _opts: optparse.Values, args: list[str] + ) -> None: if len(args) < 2: raise UserError("Usage: beet replace ") @@ -60,7 +65,7 @@ def file_check(self, filepath: Path) -> None: except mediafile.FileTypeError as fte: raise UserError(fte) - def select_song(self, items: list[Item]): + def select_song(self, items: Sequence[Item]) -> Item | None: """Present a menu of matching songs and get user selection.""" ui.print_("\nMatching songs:") for i, item in enumerate(items, 1): @@ -85,7 +90,7 @@ def select_song(self, items: list[Item]): except ValueError: ui.print_("Invalid input. Please type in a number.") - def confirm_replacement(self, new_file_path: Path, song: Item): + def confirm_replacement(self, new_file_path: Path, song: Item) -> bool: """Get user confirmation for the replacement.""" original_file_path: Path = Path(song.path.decode()) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index c2a660e980..7cf422b057 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -26,11 +26,16 @@ if TYPE_CHECKING: from collections.abc import Callable, Sequence from logging import Logger + from types import FrameType from confuse import ConfigView + from gi.repository import Gst from beets.importer import ImportSession, ImportTask from beets.library import Album, Item, Library + from beets.util import CommandOutput + + from ._typing import JSONDict class ReplayGainCLIOpts(Protocol): @@ -58,7 +63,7 @@ class FatalGstreamerPluginReplayGainError(FatalReplayGainError): loading the required plugins.""" -def call(args: list[str], log: Logger, **kwargs: Any): +def call(args: Sequence[str], log: Logger, **kwargs: Any) -> CommandOutput: """Execute the command and return its output or raise a ReplayGainError on failure. """ @@ -131,7 +136,7 @@ def __init__( self.album_gain: Gain | None = None self.track_gains: list[Gain] | None = None - def _store_track_gain(self, item: Item, track_gain: Gain): + def _store_track_gain(self, item: Item, track_gain: Gain) -> None: """Store track gain for a single item in the database.""" item.r128_track_gain = None item.rg_track_gain = track_gain.gain @@ -142,7 +147,7 @@ def _store_track_gain(self, item: Item, track_gain: Gain): item, ) - def _store_album_gain(self, item: Item, album_gain: Gain): + def _store_album_gain(self, item: Item, album_gain: Gain) -> None: """Store album gain for a single item in the database. The caller needs to ensure that `self.album_gain is not None`. @@ -156,7 +161,7 @@ def _store_album_gain(self, item: Item, album_gain: Gain): item, ) - def _store_track(self, write: bool): + def _store_track(self, write: bool) -> None: """Store track gain for the first track of the task in the database.""" item = self.items[0] if self.track_gains is None or len(self.track_gains) != 1: @@ -173,7 +178,7 @@ def _store_track(self, write: bool): item.try_write() self._log.debug("done analyzing {}", item) - def _store_album(self, write: bool): + def _store_album(self, write: bool) -> None: """Store track/album gains for all tracks of the task in the database.""" if ( self.album_gain is None @@ -194,7 +199,7 @@ def _store_album(self, write: bool): item.try_write() self._log.debug("done analyzing {}", item) - def store(self, write: bool): + def store(self, write: bool) -> None: """Store computed gains for the items of this task in the database.""" if self.album is not None: self._store_album(write) @@ -223,14 +228,14 @@ def __init__( # R128_* tags do not store the track/album peak super().__init__(items, album, target_level, None, backend_name, log) - def _store_track_gain(self, item: Item, track_gain: Gain): + def _store_track_gain(self, item: Item, track_gain: Gain) -> None: item.rg_track_gain = None item.rg_track_peak = None item.r128_track_gain = track_gain.gain item.store() self._log.debug("applied r128 track gain {.r128_track_gain} LU", item) - def _store_album_gain(self, item: Item, album_gain: Gain): + def _store_album_gain(self, item: Item, album_gain: Gain) -> None: """ The caller needs to ensure that `self.album_gain is not None`. @@ -296,8 +301,10 @@ def __init__(self, config: ConfigView, log: Logger) -> None: if b"--enable-libebur128" in line: incompatible_ffmpeg = False if line.startswith(b"libavfilter"): - version = line.split(b" ", 1)[1].split(b"/", 1)[0].split(b".") - version = tuple(map(int, version)) + version_parts = ( + line.split(b" ", 1)[1].split(b"/", 1)[0].split(b".") + ) + version = tuple(map(int, version_parts)) if version >= (6, 67, 100): incompatible_ffmpeg = False if incompatible_ffmpeg: @@ -343,7 +350,7 @@ def compute_album_gain(self, task: AnyRgTask) -> AnyRgTask: # Total number of BS.1770 gating blocks n_blocks = sum(nb for _tg, nb in track_results) - def sum_of_track_powers(track_gain: Gain, track_n_blocks: int): + def sum_of_track_powers(track_gain: Gain, track_n_blocks: int) -> float: # convert `LU to target_level` -> LUFS loudness = target_level_lufs - track_gain.gain @@ -469,10 +476,13 @@ def _analyse_item( continue if line.endswith(b"Summary:"): continue - line = line.split(b"M:", 1) - if len(line) < 2: + line_parts = line.split(b"M:", 1) + if len(line_parts) < 2: continue - if self._parse_float(b"M: " + line[1]) >= gating_threshold: + if ( + self._parse_float(b"M: " + line_parts[1]) + >= gating_threshold + ): n_blocks += 1 self._log.debug( "{}: {} blocks over {} LUFS", item, n_blocks, gating_threshold @@ -820,7 +830,7 @@ def __init__(self, config: ConfigView, log: Logger) -> None: self._files: list[bytes] = [] - def _import_gst(self): + def _import_gst(self) -> None: """Import the necessary GObject-related modules and assign `Gst` and `GObject` fields on this object. """ @@ -850,7 +860,9 @@ def _import_gst(self): self.GLib = GLib self.Gst = Gst - def compute(self, items: Sequence[Item], target_level: float, album: bool): + def compute( + self, items: Sequence[Item], target_level: float, album: bool + ) -> None: if len(items) == 0: return @@ -923,10 +935,10 @@ def compute_album_gain(self, task: AnyRgTask) -> AnyRgTask: task.track_gains = track_gains return task - def close(self): + def close(self) -> None: self._bus.remove_signal_watch() - def _on_eos(self, bus, message): + def _on_eos(self, bus: str, message: Gst.Message) -> None: # A file finished playing in all elements of the pipeline. The # RG tags have already been propagated. If we don't have a next # file, we stop processing. @@ -934,7 +946,7 @@ def _on_eos(self, bus, message): self._pipe.set_state(self.Gst.State.NULL) self._main_loop.quit() - def _on_error(self, bus, message): + def _on_error(self, bus: str, message: Gst.Message) -> None: self._pipe.set_state(self.Gst.State.NULL) self._main_loop.quit() err, debug = message.parse_error() @@ -944,10 +956,10 @@ def _on_error(self, bus, message): f"Error {err!r} - {debug!r} on file {f!r}" ) - def _on_tag(self, bus, message): + def _on_tag(self, bus: str, message: Gst.Message) -> None: tags = message.parse_tag() - def handle_tag(taglist, tag, userdata): + def handle_tag(taglist: Gst.TagList, tag: bool, userdata: Any) -> None: # The rganalysis element provides both the existing tags for # files and the new computes tags. In order to ensure we # store the computed tags, we overwrite the RG values of @@ -1039,12 +1051,12 @@ def _set_next_file(self) -> bool: return ret - def _on_pad_added(self, decbin, pad): + def _on_pad_added(self, decbin: Gst.Element, pad: Gst.Pad) -> None: sink_pad = self._conv.get_compatible_pad(pad, None) assert sink_pad is not None pad.link(sink_pad) - def _on_pad_removed(self, decbin, pad): + def _on_pad_removed(self, decbin: Gst.Element, pad: Gst.Pad) -> None: # Called when the decodebin element is disconnected from the # rest of the pipeline while switching input files peer = pad.get_peer() @@ -1063,7 +1075,7 @@ def __init__(self, config: ConfigView, log: Logger) -> None: super().__init__(config, log) self._import_audiotools() - def _import_audiotools(self): + def _import_audiotools(self) -> None: """Check whether it's possible to import the necessary modules. There is no check on the file formats at runtime. @@ -1079,7 +1091,7 @@ def _import_audiotools(self): self._mod_audiotools = audiotools self._mod_replaygain = audiotools.replaygain - def open_audio_file(self, item: Item): + def open_audio_file(self, item: Item) -> Any: """Open the file to read the PCM stream from the using ``item.path``. @@ -1099,7 +1111,7 @@ def open_audio_file(self, item: Item): return audiofile - def init_replaygain(self, audiofile, item: Item): + def init_replaygain(self, audiofile: Any, item: Item) -> Any: """Return an initialized :class:`audiotools.replaygain.ReplayGain` instance, which requires the sample rate of the song(s) on which the ReplayGain values will be computed. The item is passed in case @@ -1125,14 +1137,16 @@ def compute_track_gain(self, task: AnyRgTask) -> AnyRgTask: task.track_gains = gains return task - def _with_target_level(self, gain: float, target_level: float): + def _with_target_level(self, gain: float, target_level: float) -> float: """Return `gain` relative to `target_level`. Assumes `gain` is relative to 89 db. """ return gain + (target_level - 89) - def _title_gain(self, rg, audiofile, target_level: float): + def _title_gain( + self, rg: Any, audiofile: Any, target_level: float + ) -> tuple[float, float]: """Get the gain result pair from PyAudioTools using the `ReplayGain` instance `rg` for the given `audiofile`. @@ -1150,7 +1164,7 @@ def _title_gain(self, rg, audiofile, target_level: float): raise ReplayGainError("audiotools audio data error") return self._with_target_level(gain, target_level), peak - def _compute_track_gain(self, item: Item, target_level: float): + def _compute_track_gain(self, item: Item, target_level: float) -> Gain: """Compute ReplayGain value for the requested item. :rtype: :class:`Gain` @@ -1228,7 +1242,7 @@ def __init__( self._stopevent = Event() Thread.__init__(self) - def run(self): + def run(self) -> None: while not self._stopevent.is_set(): try: exc = self._queue.get_nowait() @@ -1239,7 +1253,7 @@ def run(self): # whether `_stopevent` is set pass - def join(self, timeout: float | None = None): + def join(self, timeout: float | None = None) -> None: self._stopevent.set() Thread.join(self, timeout) @@ -1392,7 +1406,9 @@ def create_task( self._log, ) - def handle_album(self, album: Album, write: bool, force: bool = False): + def handle_album( + self, album: Album, write: bool, force: bool = False + ) -> None: """Compute album and track replay gain store it in all of the album's items. @@ -1424,7 +1440,7 @@ def handle_album(self, album: Album, write: bool, force: bool = False): else: discs[1] = list(album.items()) - def store_cb(task: RgTask): + def store_cb(task: RgTask) -> None: task.store(write) for discnumber, items in discs.items(): @@ -1441,7 +1457,9 @@ def store_cb(task: RgTask): except FatalReplayGainError as e: raise UserError(f"Fatal replay gain error: {e}") - def handle_track(self, item: Item, write: bool, force: bool = False): + def handle_track( + self, item: Item, write: bool, force: bool = False + ) -> None: """Compute track replay gain and store it in the item. If ``write`` is truthy then ``item.write()`` is called to write @@ -1454,7 +1472,7 @@ def handle_track(self, item: Item, write: bool, force: bool = False): use_r128 = self.should_use_r128(item) - def store_cb(task: RgTask): + def store_cb(task: RgTask) -> None: task.store(write) task = self.create_task([item], use_r128) @@ -1470,7 +1488,7 @@ def store_cb(task: RgTask): except FatalReplayGainError as e: raise UserError(f"Fatal replay gain error: {e}") - def open_pool(self, threads: int): + def open_pool(self, threads: int) -> None: """Open a `ThreadPool` instance in `self.pool`""" if self.pool is None and self.backend_instance.do_parallel: self.pool = ThreadPool(threads) @@ -1487,29 +1505,29 @@ def open_pool(self, threads: int): def _apply( self, func: Callable[..., AnyRgTask], - args: list[Any], - kwds: dict[str, Any], + args: Sequence[RgTask], + kwds: JSONDict, callback: Callable[[AnyRgTask], Any], - ): + ) -> None: if self.pool is not None: # Apply the caller's context to both the worker and its callbacks # so lazy path expansion keeps the library root in pool threads. ctx = contextvars.copy_context() - def handle_exc(exc): + def handle_exc(exc: BaseException) -> None: """Handle exceptions in the async work.""" if isinstance(exc, ReplayGainError): self._log.info(exc.args[0]) # Log non-fatal exceptions. else: self.exc_queue.put(exc) - def run_func(): + def run_func() -> Any: return ctx.run(func, *args, **kwds) - def run_callback(task: AnyRgTask): + def run_callback(task: AnyRgTask) -> Any: return ctx.run(callback, task) - def run_handle_exc(exc): + def run_handle_exc(exc: BaseException) -> Any: return ctx.run(handle_exc, exc) self.pool.apply_async( @@ -1518,7 +1536,7 @@ def run_handle_exc(exc): else: callback(func(*args, **kwds)) - def terminate_pool(self): + def terminate_pool(self) -> None: """Forcibly terminate the `ThreadPool` instance in `self.pool` Sends SIGTERM to all processes. @@ -1531,7 +1549,7 @@ def terminate_pool(self): # self.exc_watcher.join() self.pool = None - def _interrupt(self, signal, frame): + def _interrupt(self, signal: int, frame: FrameType | None) -> None: try: self._log.info("interrupted") self.terminate_pool() @@ -1540,7 +1558,7 @@ def _interrupt(self, signal, frame): # Silence raised SystemExit ~ exit(0) pass - def close_pool(self): + def close_pool(self) -> None: """Regularly close the `ThreadPool` instance in `self.pool`.""" if self.pool is not None: self.pool.close() @@ -1563,7 +1581,7 @@ def import_end(self, lib: Library, paths: list[bytes]) -> None: """Handle `import` event -> close pool""" self.close_pool() - def imported(self, session: ImportSession, task: ImportTask): + def imported(self, session: ImportSession, task: ImportTask) -> None: """Add replay gain info to items or albums of ``task``.""" if self.config["auto"]: if task.is_album: @@ -1575,7 +1593,7 @@ def imported(self, session: ImportSession, task: ImportTask): def command_func( self, lib: Library, opts: ReplayGainCLIOpts, args: list[str] - ): + ) -> None: try: write = ui.should_write(opts.write) force = opts.force diff --git a/beetsplug/rewrite.py b/beetsplug/rewrite.py index bd4c8429b8..dea22f546b 100644 --- a/beetsplug/rewrite.py +++ b/beetsplug/rewrite.py @@ -2,15 +2,23 @@ formats. """ +from __future__ import annotations + import re from collections import defaultdict from functools import singledispatch -from typing import Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from beets import library from beets.exceptions import UserError from beets.plugins import BeetsPlugin +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + + from beets.library import LibModel + + T = TypeVar("T") @@ -28,12 +36,12 @@ def _(value: str, pat: re.Pattern[str], repl: str) -> str: @rewrite_value.register(list) -def _(value: list[str], pat: re.Pattern[str], repl: str) -> list[str]: +def _(value: Iterable[str], pat: re.Pattern[str], repl: str) -> list[str]: return [rewrite_value(v, pat, repl) for v in value] def apply_rewrite_rules( - value: T, rules: list[tuple[re.Pattern[str], str]] + value: T, rules: Iterable[tuple[re.Pattern[str], str]] ) -> T: """Apply all matching rewrite rules to the given value.""" for pattern, replacement in rules: @@ -42,20 +50,22 @@ def apply_rewrite_rules( return value -def rewriter(field, rules): +def rewriter( + field: str, rules: Iterable[tuple[re.Pattern[str], str]] +) -> Callable[[LibModel], str]: """Create a template field function that rewrites the given field with the given rewriting rules. ``rules`` must be a list of (pattern, replacement) pairs. """ - def fieldfunc(item): + def fieldfunc(item: LibModel) -> str: return apply_rewrite_rules(item._values_fixed[field], rules) return fieldfunc class RewritePlugin(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add({}) @@ -71,12 +81,12 @@ def __init__(self): if fieldname not in library.Item._fields: raise UserError(f"invalid field name ({fieldname}) in rewriter") self._log.debug("adding template field {}", key) - pattern = re.compile(pattern.lower()) - rules[fieldname].append((pattern, value)) + compiled_pattern = re.compile(pattern.lower()) + rules[fieldname].append((compiled_pattern, value)) if fieldname == "artist": # Special case for the artist field: apply the same # rewrite for "albumartist" as well. - rules["albumartist"].append((pattern, value)) + rules["albumartist"].append((compiled_pattern, value)) # Replace each template field with the new rewriter function. for fieldname, fieldrules in rules.items(): diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index 3eacf29892..ee3ee4f5b1 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Any, Protocol import mediafile import mutagen @@ -14,7 +14,7 @@ if TYPE_CHECKING: from beets.importer import ImportSession, ImportTask - from beets.library import Library + from beets.library import Item, Library class ScrubCLIOpts(Protocol): @@ -50,7 +50,7 @@ def __init__(self) -> None: if self.config["auto"]: self.register_listener("import_task_files", self.import_task_files) - def commands(self): + def commands(self) -> list[ui.Subcommand]: def scrub_func( lib: Library, opts: ScrubCLIOpts, args: list[str] ) -> None: @@ -73,7 +73,7 @@ def scrub_func( return [scrub_cmd] @staticmethod - def _mutagen_classes(): + def _mutagen_classes() -> list[type[Any]]: """Get a list of file type classes from the Mutagen module.""" classes = [] for modname, clsname in _MUTAGEN_FORMATS.items(): @@ -81,7 +81,7 @@ def _mutagen_classes(): classes.append(getattr(mod, clsname)) return classes - def _scrub(self, path): + def _scrub(self, path: bytes) -> None: """Remove all tags from a file.""" for cls in self._mutagen_classes(): # Try opening the file with this type, but just skip in the @@ -108,7 +108,7 @@ def _scrub(self, path): "could not scrub {}: {}", util.displayable_path(path), exc ) - def _scrub_item(self, item, restore): + def _scrub_item(self, item: Item, restore: bool) -> None: """Remove tags from an Item's associated file and, if `restore` is enabled, write the database's tags back to the file. """ diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 1f69955dfb..7fe075a74d 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -6,7 +6,7 @@ from collections import defaultdict from functools import cached_property from shlex import quote as shell_quote -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeAlias +from typing import TYPE_CHECKING, Literal, Protocol, TypeAlias from urllib.parse import quote from urllib.request import pathname2url @@ -32,6 +32,8 @@ from beets.library import LibModel, Library + from ._typing import JSONDict + QueryAndSort = tuple[Query, Sort] PlaylistQuery = Query | tuple[QueryAndSort, ...] | None PlaylistQueryAndSort = tuple[PlaylistQuery, Sort | None] @@ -222,7 +224,7 @@ def update_cmd( self.update_playlists(lib) def _parse_one_query( - self, playlist: dict[str, Any], key: str, model_cls: type + self, playlist: JSONDict, key: str, model_cls: type ) -> tuple[PlaylistQuery, Sort | None]: qs = playlist.get(key) if qs is None: diff --git a/beetsplug/subsonicplaylist.py b/beetsplug/subsonicplaylist.py index 69d149b724..817020ec16 100644 --- a/beetsplug/subsonicplaylist.py +++ b/beetsplug/subsonicplaylist.py @@ -16,14 +16,20 @@ if TYPE_CHECKING: import optparse + from collections.abc import Collection, Sequence - from beets.library import Library + from beets.library import Item, Library + + from ._typing import JSONDict __author__ = "https://github.com/MrNuggelz" +TrackKey = tuple[str, str, str] -def filter_to_be_removed(items, keys): +def filter_to_be_removed( + items: Sequence[Item], keys: Collection[TrackKey] +) -> list[Item]: if len(items) > len(keys): dont_remove = [] for artist, album, title in keys: @@ -36,7 +42,7 @@ def filter_to_be_removed(items, keys): dont_remove.append(item) return [item for item in items if item not in dont_remove] - def to_be_removed(item): + def to_be_removed(item: Item) -> bool: for artist, album, title in keys: if ( artist == item["artist"] @@ -50,7 +56,7 @@ def to_be_removed(item): class SubsonicPlaylistPlugin(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add( { @@ -63,7 +69,9 @@ def __init__(self): ) self.config["password"].redact = True - def update_tags(self, playlist_dict, lib): + def update_tags( + self, playlist_dict: dict[TrackKey, str], lib: Library + ) -> None: with lib.transaction(): for query, playlist_tag in playlist_dict.items(): query = AndQuery( @@ -83,7 +91,9 @@ def update_tags(self, playlist_dict, lib): item.subsonic_playlist = playlist_tag item.try_sync(write=True, move=False) - def get_playlist(self, playlist_id): + def get_playlist( + self, playlist_id: str + ) -> tuple[str, list[TrackKey]] | None: xml = self.send("getPlaylist", {"id": playlist_id}).text playlist = ElementTree.fromstring(xml)[0] if playlist.attrib.get("code", "200") != "200": @@ -98,7 +108,7 @@ def get_playlist(self, playlist_id): ] return name, tracks - def commands(self): + def commands(self) -> list[Subcommand]: def build_playlist( lib: Library, opts: optparse.Values, args: list[str] ) -> None: @@ -146,14 +156,16 @@ def build_playlist( subsonicplaylist_cmds.func = build_playlist return [subsonicplaylist_cmds] - def generate_token(self): + def generate_token(self) -> tuple[str, str]: salt = "".join(random.choices(string.ascii_lowercase + string.digits)) return ( md5((self.config["password"].get() + salt).encode()).hexdigest(), salt, ) - def send(self, endpoint, params=None): + def send( + self, endpoint: str, params: JSONDict | None = None + ) -> requests.Response: if params is None: params = {} a, b = self.generate_token() @@ -167,7 +179,7 @@ def send(self, endpoint, params=None): timeout=10, ) - def get_playlists(self, ids): + def get_playlists(self, ids: Sequence[str]) -> dict[TrackKey, str]: output = {} for playlist_id in ids: name, tracks = self.get_playlist(playlist_id) diff --git a/beetsplug/subsonicupdate.py b/beetsplug/subsonicupdate.py index cefd2e1066..308a408fde 100644 --- a/beetsplug/subsonicupdate.py +++ b/beetsplug/subsonicupdate.py @@ -57,7 +57,7 @@ def db_change(self, lib: Library, model: LibModel) -> None: def spl_update(self) -> None: self.register_listener("cli_exit", self.start_scan) - def __create_token(self): + def __create_token(self) -> tuple[str, str]: """Create salt and token from given password. :return: The generated salt and hashed token @@ -73,7 +73,7 @@ def __create_token(self): # Put together the payload of the request to the server and the URL return salt, token - def __format_url(self, endpoint): + def __format_url(self, endpoint: str) -> str: """Get the Subsonic URL to trigger the given endpoint. Uses either the url config option or the deprecated host, port, and context_path config options together. diff --git a/beetsplug/substitute.py b/beetsplug/substitute.py index a68594df1c..a9a496f063 100644 --- a/beetsplug/substitute.py +++ b/beetsplug/substitute.py @@ -16,7 +16,7 @@ class Substitute(BeetsPlugin): replacement) pairs. """ - def tmpl_substitute(self, text): + def tmpl_substitute(self, text: str) -> str: """Do the actual replacing.""" if text: for pattern, replacement in self.substitute_rules: @@ -24,7 +24,7 @@ def tmpl_substitute(self, text): return text return "" - def __init__(self): + def __init__(self) -> None: """Initialize the substitute plugin. Get the configuration, register template function and create list of diff --git a/beetsplug/the.py b/beetsplug/the.py index d40cafe686..79491a20fd 100644 --- a/beetsplug/the.py +++ b/beetsplug/the.py @@ -16,7 +16,7 @@ class ThePlugin(BeetsPlugin): patterns: ClassVar[list[str]] = [] - def __init__(self): + def __init__(self) -> None: super().__init__() self.template_funcs["the"] = self.the_template_func @@ -50,7 +50,7 @@ def __init__(self): if not self.patterns: self._log.warning("no patterns defined!") - def unthe(self, text, pattern): + def unthe(self, text: str, pattern: str) -> str: """Moves pattern in the path format string or strips it text -- text to handle @@ -72,7 +72,7 @@ def unthe(self, text, pattern): else: return "" - def the_template_func(self, text): + def the_template_func(self, text: str) -> str: if not self.patterns: return text if text: diff --git a/beetsplug/titlecase.py b/beetsplug/titlecase.py index 536617e6e6..831db31d5b 100644 --- a/beetsplug/titlecase.py +++ b/beetsplug/titlecase.py @@ -141,7 +141,7 @@ def all_lowercase(self) -> bool: def the_artist_regexp(self) -> re.Pattern[str]: return re.compile(r"\bthe\b") - def titlecase_callback(self, word, **kwargs) -> str | None: + def titlecase_callback(self, word: str, **kwargs) -> str | None: """Callback function for words to preserve case of.""" if preserved_word := self.preserve["words"].get(word.upper(), ""): return preserved_word diff --git a/beetsplug/types.py b/beetsplug/types.py index a01cb82756..cbd0cf11a6 100644 --- a/beetsplug/types.py +++ b/beetsplug/types.py @@ -6,18 +6,18 @@ class TypesPlugin(BeetsPlugin): @property - def item_types(self): + def item_types(self) -> dict[str, types.Type]: return self._types() @property - def album_types(self): + def album_types(self) -> dict[str, types.Type]: return self._types() - def _types(self): + def _types(self) -> dict[str, types.Type]: if not self.config.exists(): return {} - mytypes = {} + mytypes: dict[str, types.Type] = {} for key, value in self.config.items(): if value.get() == "int": mytypes[key] = types.INTEGER diff --git a/beetsplug/unimported.py b/beetsplug/unimported.py index 6d5524cb23..d9ca03a46f 100644 --- a/beetsplug/unimported.py +++ b/beetsplug/unimported.py @@ -22,11 +22,11 @@ class Unimported(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add({"ignore_extensions": [], "ignore_subdirectories": []}) - def commands(self): + def commands(self) -> list[Subcommand]: def print_unimported( lib: Library, opts: optparse.Values, args: list[str] ) -> None: diff --git a/beetsplug/zero.py b/beetsplug/zero.py index 6c5314c8bc..793446306b 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -3,7 +3,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import confuse from mediafile import MediaFile @@ -14,10 +14,13 @@ if TYPE_CHECKING: import optparse + from collections.abc import Iterable from beets.importer import ImportSession, ImportTask from beets.library import Item, Library + from ._typing import JSONDict + __author__ = "baobab@heresiarch.info" @@ -77,7 +80,7 @@ def __init__(self) -> None: ): self._set_pattern(field) - def commands(self): + def commands(self) -> list[Subcommand]: zero_command = Subcommand("zero", help="set fields to null") def zero_fields( @@ -93,7 +96,7 @@ def zero_fields( zero_command.func = zero_fields return [zero_command] - def _set_pattern(self, field): + def _set_pattern(self, field: str) -> None: """Populate `self.fields_to_progs` for a given field. Do some sanity checks then compile the regexes. """ @@ -120,13 +123,11 @@ def import_task_choice_event( self.warned = True # TODO request write in as-is mode - def write_event( - self, item: Item, path: bytes, tags: dict[str, Any] - ) -> None: + def write_event(self, item: Item, path: bytes, tags: JSONDict) -> None: if self.config["auto"]: self.set_fields(item, tags) - def set_fields(self, item, tags): + def set_fields(self, item: Item, tags: JSONDict) -> bool: """Set values in `tags` to `None` if the field is in `self.fields_to_progs` and any of the corresponding `progs` matches the field value. @@ -159,7 +160,7 @@ def set_fields(self, item, tags): return fields_set - def process_item(self, item): + def process_item(self, item: Item) -> None: tags = dict(item) if self.set_fields(item, tags): @@ -168,7 +169,7 @@ def process_item(self, item): item.store(fields=tags) -def _match_progs(value, progs): +def _match_progs(value: str, progs: Iterable[re.Pattern[str]]) -> bool: """Check if `value` (as string) is matching any of the compiled regexes in the `progs` list. """ diff --git a/docs/conf.py b/docs/conf.py index bfeffc3700..86e0c5d3ef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -142,11 +142,11 @@ html_css_files = ["beets.css"] -def skip_member(app, what, name, obj, skip, options): +def skip_member(app, what, name: str, obj, skip, options): if name.startswith("_"): return True return skip -def setup(app): +def setup(app) -> None: app.connect("autodoc-skip-member", skip_member) diff --git a/extra/release.py b/extra/release.py index d9cdbc47d6..664dc42df3 100755 --- a/extra/release.py +++ b/extra/release.py @@ -260,7 +260,7 @@ def changelog_as_markdown(rst: str) -> str: @click.group() -def cli(): +def cli() -> None: pass @@ -272,7 +272,7 @@ def bump(version: Version) -> None: @cli.command() -def changelog(): +def changelog() -> None: """Get the most recent version's changelog as Markdown.""" if changelog := get_changelog_contents(): try: From e10150dc891124493dc5ebb50c5b4183686afd11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 19 Aug 2026 04:21:59 +0100 Subject: [PATCH 2/6] typing: fix missing types in plugins --- beets/util/__init__.py | 11 ++-- beetsplug/absubmit.py | 46 ++++++++-------- beetsplug/advancedrewrite.py | 4 +- beetsplug/badfiles.py | 32 ++++++------ beetsplug/bareasc.py | 5 +- beetsplug/bpsync.py | 22 ++++---- beetsplug/bucket.py | 20 +++---- beetsplug/duplicates.py | 98 +++++++++++++++++------------------ beetsplug/edit.py | 18 ++++--- beetsplug/fish.py | 7 ++- beetsplug/hook.py | 3 +- beetsplug/importadded.py | 2 +- beetsplug/info.py | 26 ++++++---- beetsplug/inline.py | 4 +- beetsplug/ipfs.py | 17 +++--- beetsplug/lastimport.py | 36 ++++++++----- beetsplug/mbsync.py | 3 +- beetsplug/missing.py | 2 +- beetsplug/permissions.py | 14 ++--- beetsplug/play.py | 6 +-- beetsplug/replaygain.py | 6 ++- beetsplug/subsonicplaylist.py | 16 +++--- beetsplug/the.py | 9 ++-- test/plugins/test_badfiles.py | 31 ++++++++++- 24 files changed, 250 insertions(+), 188 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index d4c89c2a99..17d6652137 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -943,7 +943,9 @@ def editor_command() -> str: ) -def interactive_open(targets: Sequence[str], command: str) -> None: +def interactive_open( + targets: Sequence[Path | str | bytes], command: str +) -> None: """Open the files in `targets` by `exec`ing a new `command`, given as a Unicode string. (The new program takes over, and Python execution ends: this does not fork a subprocess.) @@ -958,11 +960,10 @@ def interactive_open(targets: Sequence[str], command: str) -> None: except ValueError: # Malformed shell tokens. args = [command] - args.insert(0, args[0]) # for argv[0] - - args += targets + first, *rest = args - os.execlp(*args) + # 'first' is duplicated because of argv[0] + os.execlp(*[first, first, *rest, *targets]) def case_sensitive(path: AnyStr) -> bool: diff --git a/beetsplug/absubmit.py b/beetsplug/absubmit.py index cd0d5c05dd..865e94fdfb 100644 --- a/beetsplug/absubmit.py +++ b/beetsplug/absubmit.py @@ -58,19 +58,18 @@ def __init__(self) -> None: {"extractor": "", "force": False, "pretend": False, "base_url": ""} ) - self.extractor = self.config["extractor"].as_str() - if self.extractor: - self.extractor = util.normpath(self.extractor) + if extractor := self.config["extractor"].as_str(): + extractor = os.fsdecode(util.normpath(extractor)) # Explicit path to extractor - if not os.path.isfile(self.extractor): + if not os.path.isfile(extractor): raise UserError( - f"Extractor command does not exist: {self.extractor}." + f"Extractor command does not exist: {extractor}." ) else: # Implicit path to extractor, search for it in path - self.extractor = "streaming_extractor_music" + extractor = "streaming_extractor_music" try: - call([self.extractor]) + call([extractor]) except OSError: raise UserError( "No extractor command found: please install the extractor" @@ -83,13 +82,21 @@ def __init__(self) -> None: # Get the executable location on the system, which we need # to calculate the SHA-1 hash. - self.extractor = shutil.which(self.extractor) + if extractor_cmd_path := shutil.which(extractor): + extractor = extractor_cmd_path + else: + raise UserError( + f"Path to extractor command {extractor} not found" + ) + + self.extractor = extractor # Calculate extractor hash. - self.extractor_sha = hashlib.sha1() - with open(self.extractor, "rb") as extractor: - self.extractor_sha.update(extractor.read()) - self.extractor_sha = self.extractor_sha.hexdigest() + extractor_sha = hashlib.sha1() + if extractor: + with open(extractor, "rb") as f: + extractor_sha.update(f.read()) + self.extractor_sha = extractor_sha.hexdigest() self.url = "" base_url = self.config["base_url"].as_str() @@ -179,13 +186,11 @@ def _get_analysis(self, item: Item) -> JSONDict | None: call([self.extractor, util.syspath(item.path), filename]) except ABSubmitError as e: self._log.warning( - "Failed to analyse {item} for AcousticBrainz: {error}", - item=item, - error=e, + "Failed to analyse {} for AcousticBrainz: {}", item, e ) return None - with open(filename) as tmp_file: - analysis = json.load(tmp_file) + with open(filename) as f: + analysis = json.load(f) # Add the hash to the output. analysis["metadata"]["version"]["essentia_build_sha"] = ( self.extractor_sha @@ -212,10 +217,9 @@ def _submit_data(self, item: Item, data: JSONDict) -> None: except (ValueError, KeyError) as e: message = f"unable to get error message: {e}" self._log.error( - "Failed to submit AcousticBrainz analysis of {item}: " - "{message}).", - item=item, - message=message, + "Failed to submit AcousticBrainz analysis for {}: {}.", + item, + message, ) else: self._log.debug( diff --git a/beetsplug/advancedrewrite.py b/beetsplug/advancedrewrite.py index 6a23c940a7..c1a54d1c94 100644 --- a/beetsplug/advancedrewrite.py +++ b/beetsplug/advancedrewrite.py @@ -50,7 +50,9 @@ def fieldfunc(item: LibModel) -> str: for query, replacement in advanced_rules: if query.match(item): # Rewrite activated. - return replacement + # TODO: BeetsPlugin.template_fields and album_template_fields + # require return value 'str' but 'list' here is legit too + return replacement # type: ignore[return-value] # Not activated; return original value. return value diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index 058a6e68e1..4fc3edb5ba 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -79,8 +79,8 @@ def run_command(self, cmd: Sequence[str]) -> tuple[int, int, list[str]]: status = e.returncode except OSError as e: raise CheckerCommandError(cmd, e) - output = output.decode(sys.getdefaultencoding(), "replace") - return status, errors, [line for line in output.split("\n") if line] + output_str = output.decode(sys.getdefaultencoding(), "replace") + return status, errors, [line for line in output_str.split("\n") if line] def check_mp3val(self, path: str) -> tuple[int, int, list[str]]: status, errors, output = self.run_command(["mp3val", path]) @@ -95,10 +95,8 @@ def check_flac(self, path: str) -> tuple[int, int, list[str]]: def check_custom( self, command: str ) -> Callable[[str], tuple[int, int, list[str]]]: - def checker(path: str): - cmd = shlex.split(command) - cmd.append(path) - return self.run_command(cmd) + def checker(path: str) -> tuple[int, int, list[str]]: + return self.run_command([*shlex.split(command), path]) return checker @@ -107,15 +105,17 @@ def get_checker( ) -> Callable[[str], tuple[int, int, list[str]]] | None: ext = ext.lower() try: - command = self.config["commands"].get(dict).get(ext) - except confuse.NotFoundError: - command = None - if command: + command = self.config["commands"].get(confuse.MappingValues(str))[ + ext + ] + except (confuse.NotFoundError, KeyError): + if ext == "mp3": + return self.check_mp3val + if ext == "flac": + return self.check_flac + else: return self.check_custom(command) - if ext == "mp3": - return self.check_mp3val - if ext == "flac": - return self.check_flac + return None def check_item(self, item: Item) -> list[str]: @@ -132,9 +132,7 @@ def check_item(self, item: Item) -> list[str]: if not checker: self._log.error("no checker specified in the config for {}", ext) return [] - path = item.path - if not isinstance(path, str): - path = item.path.decode(sys.getfilesystemencoding()) + path = str(item.filepath) try: status, errors, output = checker(path) except CheckerCommandError as e: diff --git a/beetsplug/bareasc.py b/beetsplug/bareasc.py index 9f4f7704e2..509ec86b77 100644 --- a/beetsplug/bareasc.py +++ b/beetsplug/bareasc.py @@ -68,8 +68,9 @@ def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand( "bareasc", help="unidecode version of beet list command" ) - cmd.parser.usage += ( - "\nExample: %prog -f '$album: $title' artist:beatles" + cmd.parser.set_usage( + cmd.parser.get_usage().rstrip() + + "\nExample: %prog -f '$album: $title' artist:beatles" ) cmd.parser.add_all_common_options() cmd.func = self.unidecode_list diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py index 3246e004fd..304c0751f4 100644 --- a/beetsplug/bpsync.py +++ b/beetsplug/bpsync.py @@ -19,7 +19,7 @@ class BPSyncCLIOpts(Protocol): move: bool | None - pretend: bool | None + pretend: bool write: bool | None @@ -28,7 +28,8 @@ def __init__(self) -> None: super().__init__() deprecate_for_user(self._log, "The 'bpsync' plugin") self.beatport_plugin = BeatportPlugin() - self.beatport_plugin.setup() + # this would cause an error but this plugin is dead + self.beatport_plugin.setup() # type: ignore[call-arg] def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand("bpsync", help="update metadata from Beatport") @@ -36,6 +37,7 @@ def commands(self) -> list[ui.Subcommand]: "-p", "--pretend", action="store_true", + default=False, help="show all changes but do nothing", ) cmd.parser.add_option( @@ -100,12 +102,12 @@ def singletons( continue # Apply. - trackinfo = self.beatport_plugin.track_for_id(item.mb_trackid) - with lib.transaction(): - TrackMatch(Distance(), trackinfo, item).apply_metadata( - from_scratch=False - ) - apply_item_changes(lib, item, move, pretend, write) + if trackinfo := self.beatport_plugin.track_for_id(item.mb_trackid): + with lib.transaction(): + TrackMatch(Distance(), trackinfo, item).apply_metadata( + from_scratch=False + ) + apply_item_changes(lib, item, move, pretend, write) @staticmethod def is_beatport_track(item: Item) -> bool: @@ -166,9 +168,7 @@ def albums( beatport_trackid_to_trackinfo = { track.track_id: track for track in albuminfo.tracks } - library_trackid_to_item = { - int(item.mb_trackid): item for item in items - } + library_trackid_to_item = {item.mb_trackid: item for item in items} item_info_pairs = [ (item, beatport_trackid_to_trackinfo[track_id]) for track_id, item in library_trackid_to_item.items() diff --git a/beetsplug/bucket.py b/beetsplug/bucket.py index 9745cc5353..7049b843a2 100644 --- a/beetsplug/bucket.py +++ b/beetsplug/bucket.py @@ -12,7 +12,7 @@ from beets.exceptions import UserError if TYPE_CHECKING: - from collections.abc import Iterable, Iterator + from collections.abc import Callable, Iterable, Iterator ASCII_DIGITS = string.digits + string.ascii_lowercase T = TypeVar("T") @@ -125,14 +125,15 @@ def str2fmt(s: str) -> SpanFormat: ) m = re.match(regex, s) - res = { - "fromnchars": len(m.group("fromyear")), - "tonchars": len(m.group("toyear")), - } - res["fmt"] = ( - f"{m['bef']}{{}}{m['sep']}{'{}' if res['tonchars'] else ''}{m['after']}" - ) - return res + if m: + fromnchars = len(m.group("fromyear")) + tonchars = len(m.group("toyear")) + fmt = f"{m['bef']}{{}}{m['sep']}{'{}' if tonchars else ''}{m['after']}" + else: + fromnchars = tonchars = 0 + fmt = "{}" + + return {"fromnchars": fromnchars, "tonchars": tonchars, "fmt": fmt} def format_span( @@ -246,6 +247,7 @@ def _tmpl_bucket(self, text: str, field: str | None = None) -> str: if not field and len(text) == 4 and text.isdigit(): field = "year" + func: Callable[[str], str] if field == "year": func = self.find_bucket_year else: diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py index f4e4879f76..bb53b81e89 100644 --- a/beetsplug/duplicates.py +++ b/beetsplug/duplicates.py @@ -4,6 +4,7 @@ import os import shlex +from functools import partial from typing import TYPE_CHECKING, Any from beets.library import Album, Item @@ -21,7 +22,7 @@ import optparse from collections.abc import Iterator, Sequence - from beets.library import AlbumOrItem, LibModel, Library + from beets.library import LibModel, Library PLUGIN = "duplicates" @@ -213,9 +214,9 @@ def _dup(lib: Library, opts: optparse.Values, args: list[str]) -> None: def _process_item( self, - item: Item, + model: LibModel, *, - copy: bool, + copy: bytes, move: bytes, delete: bool, tag: str, @@ -223,60 +224,60 @@ def _process_item( remove: bool, ) -> None: """Process Item `item`.""" - print_(format(item, fmt)) + print_(format(model, fmt)) if copy: - item.move(basedir=copy, operation=MoveOperation.COPY) - item.store() + model.move(basedir=copy, operation=MoveOperation.COPY) + model.store() if move: - item.move(basedir=move) - item.store() + model.move(basedir=move) + model.store() if delete: - item.remove(delete=True) + model.remove(delete=True) elif remove: - item.remove(delete=False) + model.remove(delete=False) if tag: try: k, v = tag.split("=") except Exception: raise UserError(f"{PLUGIN}: can't parse k=v tag: {tag}") - setattr(item, k, v) - item.store() + setattr(model, k, v) + model.store() - def _checksum(self, item: Item, prog: str) -> tuple[str, Any]: + def _checksum(self, model: LibModel, prog: str) -> tuple[str, Any]: """Run external `prog` on file path associated with `item`, cache output as flexattr on a key that is the name of the program, and return the key, checksum tuple. """ args = [ - p.format(file=os.fsdecode(item.path)) for p in shlex.split(prog) + p.format(file=os.fsdecode(model.path)) for p in shlex.split(prog) ] key = args[0] - checksum = getattr(item, key, False) + checksum = getattr(model, key, False) if not checksum: self._log.debug( "key {} on item {.filepath} not cached:computing checksum", key, - item, + model, ) try: checksum = command_output(args).stdout - setattr(item, key, checksum) - item.store() + setattr(model, key, checksum) + model.store() self._log.debug( - "computed checksum for {.title} using {}", item, key + "computed checksum for {.title} using {}", model, key ) except subprocess.CalledProcessError as e: - self._log.debug("failed to checksum {.filepath}: {}", item, e) + self._log.debug("failed to checksum {.filepath}: {}", model, e) else: self._log.debug( "key {} on item {.filepath} cached:not computing checksum", key, - item, + model, ) return key, checksum def _group_by( - self, objs: Sequence[AlbumOrItem], keys: Sequence[str], strict: bool + self, objs: Sequence[LibModel], keys: Sequence[str], strict: bool ) -> dict[tuple[Any, ...], list[LibModel]]: """Return a dictionary with keys arbitrary concatenations of attributes and values lists of objects (Albums or Items) with those keys. @@ -309,7 +310,7 @@ def _group_by( def _order( self, - objs: Sequence[AlbumOrItem], + objs: Sequence[LibModel], tiebreak: dict[str, list[str]] | None = None, ) -> list[LibModel]: """Return the objects (Items or Albums) sorted by descending @@ -322,33 +323,30 @@ def _order( """ kind = "items" if all(isinstance(o, Item) for o in objs) else "albums" + sort = partial(sorted, reverse=True) if tiebreak and kind in tiebreak.keys(): + return sort( + objs, key=lambda x: tuple(getattr(x, k) for k in tiebreak[kind]) + ) + if kind == "items": + + def truthy(v: object) -> bool: + # Avoid a Unicode warning by avoiding comparison + # between a bytes object and the empty Unicode + # string ''. + return v is not None and ( + v != "" if isinstance(v, str) else True + ) - def key(x: AlbumOrItem) -> tuple[Any, ...]: - return tuple(getattr(x, k) for k in tiebreak[kind]) - else: - if kind == "items": - - def truthy(v: object) -> bool: - # Avoid a Unicode warning by avoiding comparison - # between a bytes object and the empty Unicode - # string ''. - return v is not None and ( - v != "" if isinstance(v, str) else True - ) - - fields = Item.all_keys() - - def key(x: AlbumOrItem) -> int: - return sum(1 for f in fields if truthy(getattr(x, f))) - else: - - def key(x: AlbumOrItem) -> int: - return len(x.items()) + fields = Item.all_keys() - return sorted(objs, key=key, reverse=True) + return sort( + objs, + key=lambda x: sum(1 for f in fields if truthy(getattr(x, f))), + ) + return sort(objs, key=lambda x: len(x.items())) - def _merge_items(self, objs: Sequence[AlbumOrItem]) -> Sequence[Item]: + def _merge_items(self, objs: Sequence[Item]) -> Sequence[Item]: """Merge Item objs by copying missing fields from items in the tail to the head item. @@ -395,20 +393,20 @@ def _merge_albums(self, objs: Sequence[Album]) -> Sequence[Album]: missing.move(operation=MoveOperation.COPY) return objs - def _merge(self, objs: Sequence[AlbumOrItem]) -> Sequence[LibModel]: + def _merge(self, objs: Sequence[LibModel]) -> Sequence[LibModel]: """Merge duplicate items. See ``_merge_items`` and ``_merge_albums`` for the relevant strategies. """ kind = Item if all(isinstance(o, Item) for o in objs) else Album if kind is Item: - objs = self._merge_items(objs) + objs = self._merge_items(objs) # type: ignore[arg-type] else: - objs = self._merge_albums(objs) + objs = self._merge_albums(objs) # type: ignore[arg-type] return objs def _duplicates( self, - objs: Sequence[AlbumOrItem], + objs: Sequence[LibModel], keys: Sequence[str], full: bool, strict: bool, diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 80ec8d4219..3fbf93eabb 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -261,7 +261,10 @@ def edit_objects( # Show the changes. # If the objects are not on the DB yet, we need a copy of their # original state for show_model_changes. - objs_old = [obj.copy() if obj.id < 0 else None for obj in objs] + objs_old = [ + obj.copy() if (obj.id is not None and obj.id < 0) else None + for obj in objs + ] self.apply_data(objs, old_data, new_data) changed = False for obj, obj_old in zip(objs, objs_old): @@ -579,9 +582,12 @@ def importer_edit_candidate( applied to the original items. """ # Prompt the user for a candidate. - 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() + if 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() + + return self.importer_edit(session, task) - return self.importer_edit(session, task) + return None diff --git a/beetsplug/fish.py b/beetsplug/fish.py index 21b34243e8..96629f1278 100644 --- a/beetsplug/fish.py +++ b/beetsplug/fish.py @@ -152,8 +152,7 @@ def get_cmds_list(cmds_names: Iterable[str]) -> str: def get_standard_fields(fields: Iterable[str]) -> str: # Make a list of album/track fields and append with ':' - fields = (f"{field}:" for field in fields) - return f"set FIELDS {' '.join(fields)}\n\n" + return f"set FIELDS {' '.join(f'{field}:' for field in fields)}\n\n" def get_extravalues(lib: Library, extravalues: Sequence[str]) -> str: @@ -171,7 +170,7 @@ def get_set_of_values_for_field( lib: Library, fields: Sequence[str] ) -> dict[str, set[Any]]: # Get unique values from a specified album/track field - fields_dict = {} + fields_dict: dict[str, set[str]] = {} for each in fields: fields_dict[each] = set() for item in lib.items(): @@ -205,7 +204,7 @@ def get_basic_beet_options() -> str: def get_subcommands( cmd_name_and_help: Iterable[tuple[str, str]], nobasicfields: bool, - extravalues: Iterable[str], + extravalues: Iterable[str] | None, ) -> str: # Formatting for Fish to complete our fields/values word = "" diff --git a/beetsplug/hook.py b/beetsplug/hook.py index cb24a49355..c4164228d0 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -8,6 +8,7 @@ import subprocess from typing import TYPE_CHECKING, Any +from beets.events import ALL_EVENTS from beets.plugins import BeetsPlugin if TYPE_CHECKING: @@ -43,7 +44,7 @@ def __init__(self) -> None: for hook_index in range(len(hooks)): hook = self.config["hooks"][hook_index] - hook_event = hook["event"].as_str() + hook_event: EventType = hook["event"].as_choice(choices=ALL_EVENTS) hook_command = hook["command"].as_str() self.create_and_register_hook(hook_event, hook_command) diff --git a/beetsplug/importadded.py b/beetsplug/importadded.py index 588721201e..c7b6a21269 100644 --- a/beetsplug/importadded.py +++ b/beetsplug/importadded.py @@ -104,7 +104,7 @@ def write_item_mtime(self, item: Item, mtime: float) -> None: """ # The file's mtime on disk must be in sync with the item's mtime self.write_file_mtime(util.syspath(item.path), mtime) - item.mtime = mtime + item.mtime = int(mtime) def record_import_mtime( self, item: Item, source: bytes, destination: bytes diff --git a/beetsplug/info.py b/beetsplug/info.py index a80f4f432a..b92376ea45 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -15,11 +15,13 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator, Sequence - from beets.library import Library + from beets.library import LibModel, Library from ._typing import JSONDict - DataEmitter = Callable[[Literal["*"] | list[str]], tuple[JSONDict, Any]] + DataEmitter = Callable[ + [Literal["*"] | list[str]], tuple[JSONDict, LibModel] + ] class InfoCLIOpts(Protocol): @@ -54,7 +56,9 @@ def tag_fields() -> set[str]: def tag_data_emitter(path: bytes) -> DataEmitter: - def emitter(included_keys: Literal["*"] | list[str]) -> tuple[Any, Any]: + def emitter( + included_keys: Literal["*"] | list[str], + ) -> tuple[JSONDict, LibModel]: fields: set[str] | list[str] if included_keys == "*": fields = tag_fields() @@ -64,7 +68,7 @@ def emitter(included_keys: Literal["*"] | list[str]) -> tuple[Any, Any]: # We can't serialize the image data. fields.remove("images") mf = mediafile.MediaFile(syspath(path)) - tags = {} + tags: JSONDict = {} for field in fields: if field == "art": tags[field] = mf.art is not None @@ -86,11 +90,13 @@ def library_data( yield library_data_emitter(item) -def library_data_emitter(item: Item) -> DataEmitter: - def emitter(included_keys: Literal["*"] | list[str]) -> tuple[Any, Any]: - data = dict(item.formatted(included_keys=included_keys)) +def library_data_emitter(model: LibModel) -> DataEmitter: + def emitter( + included_keys: Literal["*"] | list[str], + ) -> tuple[JSONDict, LibModel]: + data = dict(model.formatted(included_keys=included_keys)) - return data, item + return data, model return emitter @@ -105,7 +111,7 @@ def update_summary(summary: JSONDict, tags: JSONDict) -> JSONDict: def print_data( - data: JSONDict, item: Item | None = None, fmt: str | None = None + data: JSONDict, item: LibModel | None = None, fmt: str | None = None ) -> None: """Print, with optional formatting, the fields of a single element. @@ -142,7 +148,7 @@ def print_data( ui.print_(f"{field:>{maxwidth}}: {value}") -def print_data_keys(data: JSONDict, item: Item | None = None) -> None: +def print_data_keys(data: JSONDict, item: LibModel | None = None) -> None: """Print only the keys (field names) for an item.""" path = displayable_path(item.path) if item else None formatted = [] diff --git a/beetsplug/inline.py b/beetsplug/inline.py index d823cf2fbb..55edf061d6 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -4,7 +4,7 @@ import itertools import traceback -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from beets import config from beets.plugins import BeetsPlugin @@ -36,7 +36,7 @@ def _compile_func(body: str, args: str = "") -> Callable[..., Any]: body = body.replace("\n", "\n ") body = f"def {FUNC_NAME}({args}):\n {body}" code = compile(body, "inline", "exec") - env = {} + env: JSONDict = {} eval(code, env) return env[FUNC_NAME] diff --git a/beetsplug/ipfs.py b/beetsplug/ipfs.py index 7500114747..91bcbd3a68 100644 --- a/beetsplug/ipfs.py +++ b/beetsplug/ipfs.py @@ -142,7 +142,7 @@ def ipfs_add(self, album: Album) -> bool: cmd = "ipfs add --nocopy -q -r".split() else: cmd = "ipfs add -q -r".split() - cmd.append(album_dir) + cmd.append(os.fsdecode(album_dir)) try: output = util.command_output(cmd).stdout.split() except (OSError, subprocess.CalledProcessError) as exc: @@ -169,8 +169,8 @@ def ipfs_add(self, album: Album) -> bool: return True - def ipfs_get(self, lib: Library, query: Sequence[str]) -> None: - query = query[0] + def ipfs_get(self, lib: Library, queries: Sequence[str]) -> None: + query = queries[0] # Check if query is a hash # TODO: generalize to other hashes; probably use a multihash # implementation @@ -228,13 +228,16 @@ def ipfs_import(self, lib: Library, args: Sequence[str]) -> bool | None: try: os.makedirs(remote_libs) except OSError as e: - msg = f"Could not create {remote_libs}. Error: {e}" - self._log.error(msg) + self._log.error( + "Could not create {}. Error: {}", + os.fsdecode(remote_libs), + e, + ) return False path = os.path.join(remote_libs, lib_name.encode() + b".db") if not os.path.exists(path): cmd = f"ipfs get {_hash} -o".split() - cmd.append(path) + cmd.append(os.fsdecode(path)) try: util.command_output(cmd) except (OSError, subprocess.CalledProcessError): @@ -319,7 +322,7 @@ def create_new_album(self, album: Album, tmplib: Library) -> bool | None: pass item_path = os.fsdecode(os.path.basename(item.path)) # Clear current path from item - item.path = f"{album.ipfs}/{item_path}" + item.path = os.fsencode(f"{album.ipfs}/{item_path}") item.id = None items.append(item) diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py index e5b1df68e9..aa35b60b2e 100644 --- a/beetsplug/lastimport.py +++ b/beetsplug/lastimport.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, NamedTuple import pylast -from pylast import TopItem, _extract, _number +from pylast import _extract, _number from beets import config, plugins, ui from beets.dbcore import types @@ -22,6 +22,15 @@ API_URL = "https://ws.audioscrobbler.com/2.0/" +class OurTrack(pylast.Track): + mbid: str + + +class OurTopItem(NamedTuple): + item: OurTrack + weight: float + + class LastImportPlugin(plugins.BeetsPlugin): def __init__(self) -> None: super().__init__() @@ -54,7 +63,6 @@ def __init__(self, *args, **kwargs) -> None: def _get_things( self, method: str, - thing: str, thing_type: type[pylast.Track | pylast.Album], params: type[pylast._Opus] | None = None, cacheable: bool = True, @@ -70,15 +78,15 @@ def _get_things( total_pages = int(toptracks_node.getAttribute("totalPages")) seq = [] - for node in doc.getElementsByTagName(thing): + for node in doc.getElementsByTagName(thing_type.__name__.lower()): title = _extract(node, "name") artist = _extract(node, "name", 1) mbid = _extract(node, "mbid") playcount = _number(_extract(node, "playcount")) - thing = thing_type(artist, title, self.network) - thing.mbid = mbid - seq.append(TopItem(thing, playcount)) + thing = OurTrack(artist, title, self.network) + thing.mbid = mbid # type: ignore[union-attr] + seq.append(OurTopItem(thing, playcount)) return seq, total_pages @@ -106,9 +114,7 @@ def get_top_tracks_by_page( if limit: params["limit"] = limit - return self._get_things( - "getTopTracks", "track", pylast.Track, params, cacheable - ) + return self._get_things("getTopTracks", pylast.Track, params, cacheable) def import_lastfm(lib: Library, log: Logger) -> None: @@ -175,10 +181,12 @@ def fetch_tracks(user: str, page: int, limit: int) -> tuple[list[Track], int]: ) return [ { - "mbid": track.item.mbid or "", - "artist": track.item.artist.name.strip(), - "name": track.item.title.strip(), - "playcount": int(track.weight), + "mbid": t.item.mbid or "", + "artist": ( + n.strip() if ((a := t.item.artist) and (n := a.name)) else "" + ), + "name": ti.strip() if ((i := t.item) and (ti := i.title)) else "", + "playcount": int(t.weight), } - for track in results + for t in results ], total_pages diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 9e366a0bde..f89d998908 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -17,7 +17,7 @@ class MBSyncCLIOpts(Protocol): move: bool | None - pretend: bool | None + pretend: bool write: bool | None @@ -31,6 +31,7 @@ def commands(self) -> list[ui.Subcommand]: "-p", "--pretend", action="store_true", + default=False, help="show all changes but do nothing", ) cmd.parser.add_option( diff --git a/beetsplug/missing.py b/beetsplug/missing.py index edb6e60b25..fbe502c89f 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -116,7 +116,7 @@ def __init__(self) -> None: } ) - self.album_template_fields["missing"] = _missing_count + self.album_template_fields["missing"] = lambda a: str(_missing_count(a)) self._command = Subcommand("missing", help=__doc__, aliases=["miss"]) self._command.parser.add_option( diff --git a/beetsplug/permissions.py b/beetsplug/permissions.py index 32d78cdf60..48597dc042 100644 --- a/beetsplug/permissions.py +++ b/beetsplug/permissions.py @@ -57,7 +57,7 @@ def assert_permissions(path: bytes, permission: int, log: Logger) -> None: def dirs_in_library(library: bytes, path: bytes) -> list[bytes]: """Creates a list of ancestor directories in the beets library path.""" return [ - ancestor for ancestor in ancestry(item) if ancestor.startswith(library) + ancestor for ancestor in ancestry(path) if ancestor.startswith(library) ][1:] @@ -78,8 +78,8 @@ def fix_item(self, lib: Library, item: Item) -> None: ) def fix_album(self, lib: Library, album: Album) -> None: - files = [] - dirs = set() + files: list[bytes] = [] + dirs: set[bytes] = set() for item in album.items(): files.append(item.path) dirs.update(dirs_in_library(lib.directory, item.path)) @@ -91,7 +91,9 @@ def fix_art(self, album: Album) -> None: self.set_permissions(files=[album.artpath]) def set_permissions( - self, files: Iterable[bytes] = [], dirs: Iterable[bytes] = [] + self, + files: Iterable[bytes] | None = None, + dirs: Iterable[bytes] | None = None, ) -> None: # Get the configured permissions. The user can specify this either a # string (in YAML quotes) or, for convenience, as an integer so the @@ -102,7 +104,7 @@ def set_permissions( file_perm = convert_perm(file_perm) dir_perm = convert_perm(dir_perm) - for path in files: + for path in files or []: # Changing permissions on the destination file. self._log.debug( "setting file permissions on {}", displayable_path(path) @@ -114,7 +116,7 @@ def set_permissions( assert_permissions(path, file_perm, self._log) # Change permissions for the directories. - for path in dirs: + for path in dirs or []: # Changing permissions on the destination directory. self._log.debug( "setting directory permissions on {}", displayable_path(path) diff --git a/beetsplug/play.py b/beetsplug/play.py index 70d403e086..ec0a518fc7 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -60,9 +60,7 @@ def play( try: if keep_open: - command = shlex.split(command_str) - command = command + open_args - subprocess.call(command) + subprocess.call([*shlex.split(command_str), *open_args]) else: util.interactive_open(open_args, command_str) except OSError as exc: @@ -180,7 +178,7 @@ def _play_command( ): play(command_str, selection, paths, open_args, self._log, item_type) - def _command_str(self, args: list[str] | None = None) -> str: + def _command_str(self, args: str | None = None) -> str: """Create a command string from the config command and optional args.""" command_str = config["play"]["command"].get() if not command_str: diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 7cf422b057..74252d68d6 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -771,6 +771,8 @@ def _parse_gain(value: str) -> float: class GStreamerBackend(Backend): NAME = "gstreamer" + _error: ReplayGainError | None = None + def __init__(self, config: ConfigView, log: Logger) -> None: super().__init__(config, log) self._import_gst() @@ -1235,7 +1237,7 @@ class ExceptionWatcher(Thread): """ def __init__( - self, queue: queue.Queue[Exception], callback: Callable[[], None] + self, queue: queue.Queue[BaseException], callback: Callable[[], None] ) -> None: self._queue = queue self._callback = callback @@ -1492,7 +1494,7 @@ def open_pool(self, threads: int) -> None: """Open a `ThreadPool` instance in `self.pool`""" if self.pool is None and self.backend_instance.do_parallel: self.pool = ThreadPool(threads) - self.exc_queue: queue.Queue[Exception] = queue.Queue() + self.exc_queue: queue.Queue[BaseException] = queue.Queue() signal.signal(signal.SIGINT, self._interrupt) diff --git a/beetsplug/subsonicplaylist.py b/beetsplug/subsonicplaylist.py index 817020ec16..e707f5a0c7 100644 --- a/beetsplug/subsonicplaylist.py +++ b/beetsplug/subsonicplaylist.py @@ -74,14 +74,14 @@ def update_tags( ) -> None: with lib.transaction(): for query, playlist_tag in playlist_dict.items(): - query = AndQuery( + and_query = AndQuery( [ MatchQuery("artist", query[0]), MatchQuery("album", query[1]), MatchQuery("title", query[2]), ] ) - items = lib.items(query) + items = lib.items(and_query) if not items: self._log.warn( "{} | track not found ({})", playlist_tag, query @@ -182,9 +182,11 @@ def send( def get_playlists(self, ids: Sequence[str]) -> dict[TrackKey, str]: output = {} for playlist_id in ids: - name, tracks = self.get_playlist(playlist_id) - for track in tracks: - if track not in output: - output[track] = ";" - output[track] += f"{name};" + playlist = self.get_playlist(playlist_id) + if playlist: + name, tracks = playlist + for track in tracks: + if track not in output: + output[track] = ";" + output[track] += f"{name};" return output diff --git a/beetsplug/the.py b/beetsplug/the.py index 79491a20fd..5cd66d2458 100644 --- a/beetsplug/the.py +++ b/beetsplug/the.py @@ -1,7 +1,6 @@ """Moves patterns in path formats (suitable for moving articles).""" import re -from typing import ClassVar from beets.plugins import BeetsPlugin @@ -14,7 +13,7 @@ class ThePlugin(BeetsPlugin): - patterns: ClassVar[list[str]] = [] + patterns: list[str] def __init__(self) -> None: super().__init__() @@ -58,13 +57,13 @@ def unthe(self, text: str, pattern: str) -> str: strip -- if True, pattern will be removed """ if text: - r = re.compile(pattern, flags=re.IGNORECASE) + m = re.compile(pattern, flags=re.IGNORECASE) try: - t = r.findall(text)[0] + t = m.findall(text)[0] except IndexError: return text else: - r = re.sub(r, "", text).strip() + r = re.sub(m, "", text).strip() if self.config["strip"]: return r fmt = self.config["format"].as_str() diff --git a/test/plugins/test_badfiles.py b/test/plugins/test_badfiles.py index b9ac3ce1d1..31d2bf64f1 100644 --- a/test/plugins/test_badfiles.py +++ b/test/plugins/test_badfiles.py @@ -4,7 +4,12 @@ from unittest.mock import patch from beets import importer -from beets.test.helper import PluginTestCase +from beets.test.helper import ( + AutotagImportTestCase, + PluginMixin, + PluginTestCase, + TerminalImportMixin, +) from beetsplug.badfiles import BadFiles @@ -32,3 +37,27 @@ def test_non_quiet_import_calls_prompt(self): result = plugin.on_import_task_before_choice(task, session=None) assert result == importer.Action.SKIP + + +class BadfilesOnImportTest( + TerminalImportMixin, PluginMixin, AutotagImportTestCase +): + plugin = "badfiles" + + def setUp(self): + super().setUp() + self.prepare_album_for_import(1) + self.importer = self.setup_importer() + + def test_play_on_import(self): + BadFiles() + self.importer.add_choice("b") + checker = self.temp_path / "checker" + checker.write_text("#!/bin/sh\nexit 1") + checker.chmod(0o755) + with self.configure_plugin( + {"check_on_import": True, "commands": {"mp3": str(checker)}} + ): + self.importer.run() + + assert not self.lib.items() From 2253f40f87beb763be39b44556261d9411ef3ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 17 Aug 2026 12:04:56 +0100 Subject: [PATCH 3/6] typing: configure flake8-annotations --- pyproject.toml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 43ac7fac69..7fa0745c81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -150,7 +150,7 @@ build-backend = "hatchling.build" [tool.hatch.build] artifacts = [ - "man/**" + "man/**", ] [tool.hatch.build.targets.wheel] @@ -295,6 +295,7 @@ skip-magic-trailing-comma = true future-annotations = true select = [ "A", # flake8-builtins + "ANN", # flake8-annotations # "ARG", # flake8-unused-arguments # "C4", # flake8-comprehensions "E", # pycodestyle @@ -313,16 +314,23 @@ select = [ "W", # pycodestyle ] ignore = [ + "ANN002", # no need to document *args due to noise + "ANN003", # no need to document **kwargs due to noise + "ANN401", # we use Any type in the codebase "TC006", # no need to quote 'cast's since we use 'from __future__ import annotations' ] [tool.ruff.lint.per-file-ignores] "beets/**" = ["PT"] +"docs/conf.py" = ["ANN"] "test/plugins/test_ftintitle.py" = ["E501"] "test/test_util.py" = ["E501"] "test/util/test_diff.py" = ["E501"] "test/util/test_id_extractors.py" = ["E501"] -"test/**" = ["RUF001"] # we use Unicode characters in tests +"test/**" = [ + "RUF001", # we use Unicode characters in tests + "ANN", # we don't annotate test functions +] [tool.ruff.lint.isort] split-on-trailing-comma = false @@ -330,6 +338,10 @@ split-on-trailing-comma = false [tool.ruff.lint.pycodestyle] max-line-length = 88 +[tool.ruff.lint.flake8-annotations] +allow-star-arg-any = true +suppress-dummy-args = true + [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false mark-parentheses = false From 4b7b7244072eb6f9d228252d3126d36f629e832a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 19 Aug 2026 05:55:42 +0100 Subject: [PATCH 4/6] Remove the need for type: ignore --- beetsplug/duplicates.py | 178 ++++++++++++++++++++-------------------- 1 file changed, 89 insertions(+), 89 deletions(-) diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py index bb53b81e89..66b9231271 100644 --- a/beetsplug/duplicates.py +++ b/beetsplug/duplicates.py @@ -20,9 +20,9 @@ if TYPE_CHECKING: import optparse - from collections.abc import Iterator, Sequence + from collections.abc import Callable, Iterator, Sequence - from beets.library import LibModel, Library + from beets.library import AlbumOrItem, LibModel, Library PLUGIN = "duplicates" @@ -141,77 +141,88 @@ def __init__(self) -> None: def commands(self) -> list[Subcommand]: def _dup(lib: Library, opts: optparse.Values, args: list[str]) -> None: self.config.set_args(opts) - album = self.config["album"].get(bool) - checksum = self.config["checksum"].get(str) - copy = bytestring_path(self.config["copy"].as_str()) - count = self.config["count"].get(bool) - delete = self.config["delete"].get(bool) - remove = self.config["remove"].get(bool) - fmt_tmpl = self.config["format"].get(str) - full = self.config["full"].get(bool) keys = self.config["keys"].as_str_seq() - merge = self.config["merge"].get(bool) - move = bytestring_path(self.config["move"].as_str()) - path = self.config["path"].get(bool) - tiebreak = self.config["tiebreak"].get(dict) - strict = self.config["strict"].get(bool) - tag = self.config["tag"].get(str) - - items: Sequence[LibModel] - if album: - if not keys: - keys = ["mb_albumid"] - items = lib.albums(args) + + if self.config["album"].get(bool): + self._run_command( + lib.albums(args), + keys or ["mb_albumid"], + "$albumartist - $album", + merge_func=self._merge_albums, + ) else: - if not keys: - keys = ["mb_trackid", "mb_albumid"] - items = lib.items(args) - - # If there's nothing to do, return early. The code below assumes - # `items` to be non-empty. - if not items: - return - - if path: - fmt_tmpl = "$path" - elif not fmt_tmpl: - if album: - fmt_tmpl = "$albumartist - $album" - else: - fmt_tmpl = "$albumartist - $album - $title" - - if checksum: - for i in items: - k, _ = self._checksum(i, checksum) - keys = [k] - - for obj_id, obj_count, objs in self._duplicates( - items, - keys=keys, - full=full, - strict=strict, - tiebreak=tiebreak, - merge=merge, - ): - if obj_id: # Skip empty IDs. - for o in objs: - self._process_item( - o, - copy=copy, - move=move, - delete=delete, - remove=remove, - tag=tag, - fmt=( - fmt_tmpl - if not count - else f"{fmt_tmpl}: {obj_count}" - ), - ) + self._run_command( + lib.items(args), + keys or ["mb_trackid", "mb_albumid"], + "$albumartist - $album - $title", + merge_func=self._merge_items, + ) self._command.func = _dup return [self._command] + def _run_command( + self, + items: Sequence[AlbumOrItem], + keys: list[str], + fmt_tmpl_fallback: str, + *, + merge_func: Callable[[list[AlbumOrItem]], list[AlbumOrItem]], + ) -> None: + """Process one homogeneous set of duplicate candidates.""" + checksum = self.config["checksum"].get(str) + copy = bytestring_path(self.config["copy"].as_str()) + count = self.config["count"].get(bool) + delete = self.config["delete"].get(bool) + remove = self.config["remove"].get(bool) + fmt_tmpl = self.config["format"].get(str) + full = self.config["full"].get(bool) + merge = self.config["merge"].get(bool) + move = bytestring_path(self.config["move"].as_str()) + path = self.config["path"].get(bool) + tiebreak = self.config["tiebreak"].get(dict) + strict = self.config["strict"].get(bool) + tag = self.config["tag"].get(str) + + # If there's nothing to do, return early. The code below assumes + # `items` to be non-empty. + if not items: + return + + if path: + fmt_tmpl = "$path" + elif not fmt_tmpl: + fmt_tmpl = fmt_tmpl_fallback + + if checksum: + for i in items: + k, _ = self._checksum(i, checksum) + keys = [k] + + for obj_id, obj_count, objs in self._duplicates( + items, + keys=keys, + full=full, + strict=strict, + tiebreak=tiebreak, + merge_func=merge_func if merge else None, + ): + if obj_id: # Skip empty IDs. + for o in objs: + self._process_item( + o, + copy=copy, + move=move, + delete=delete, + remove=remove, + tag=tag, + fmt=( + fmt_tmpl + if not count + else f"{fmt_tmpl}: {obj_count}" + ), + ) + def _process_item( self, model: LibModel, @@ -277,8 +288,8 @@ def _checksum(self, model: LibModel, prog: str) -> tuple[str, Any]: return key, checksum def _group_by( - self, objs: Sequence[LibModel], keys: Sequence[str], strict: bool - ) -> dict[tuple[Any, ...], list[LibModel]]: + self, objs: Sequence[AlbumOrItem], keys: Sequence[str], strict: bool + ) -> dict[tuple[Any, ...], list[AlbumOrItem]]: """Return a dictionary with keys arbitrary concatenations of attributes and values lists of objects (Albums or Items) with those keys. @@ -310,9 +321,9 @@ def _group_by( def _order( self, - objs: Sequence[LibModel], + objs: Sequence[AlbumOrItem], tiebreak: dict[str, list[str]] | None = None, - ) -> list[LibModel]: + ) -> list[AlbumOrItem]: """Return the objects (Items or Albums) sorted by descending order of priority. @@ -346,7 +357,7 @@ def truthy(v: object) -> bool: ) return sort(objs, key=lambda x: len(x.items())) - def _merge_items(self, objs: Sequence[Item]) -> Sequence[Item]: + def _merge_items(self, objs: list[Item]) -> list[Item]: """Merge Item objs by copying missing fields from items in the tail to the head item. @@ -370,7 +381,7 @@ def _merge_items(self, objs: Sequence[Item]) -> Sequence[Item]: break return objs - def _merge_albums(self, objs: Sequence[Album]) -> Sequence[Album]: + def _merge_albums(self, objs: list[Album]) -> list[Album]: """Merge Album objs by copying missing items from albums in the tail to the head album. @@ -393,31 +404,20 @@ def _merge_albums(self, objs: Sequence[Album]) -> Sequence[Album]: missing.move(operation=MoveOperation.COPY) return objs - def _merge(self, objs: Sequence[LibModel]) -> Sequence[LibModel]: - """Merge duplicate items. See ``_merge_items`` and ``_merge_albums`` - for the relevant strategies. - """ - kind = Item if all(isinstance(o, Item) for o in objs) else Album - if kind is Item: - objs = self._merge_items(objs) # type: ignore[arg-type] - else: - objs = self._merge_albums(objs) # type: ignore[arg-type] - return objs - def _duplicates( self, - objs: Sequence[LibModel], + objs: Sequence[AlbumOrItem], keys: Sequence[str], full: bool, strict: bool, tiebreak: dict[str, list[str]] | None, - merge: bool, - ) -> Iterator[tuple[tuple[Any, ...], int, Sequence[LibModel]]]: + merge_func: Callable[[list[AlbumOrItem]], list[AlbumOrItem]] | None, + ) -> Iterator[tuple[tuple[Any, ...], int, Sequence[AlbumOrItem]]]: """Generate triples of keys, duplicate counts, and constituent objects.""" offset = 0 if full else 1 for k, objs in self._group_by(objs, keys, strict).items(): if len(objs) > 1: objs = self._order(objs, tiebreak) - if merge: - objs = self._merge(objs) + if merge_func: + objs = merge_func(objs) yield (k, len(objs) - offset, objs[offset:]) From 26c5f04ecec7c2052bf4bac688bdd6c35d8d9816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 19 Aug 2026 05:56:04 +0100 Subject: [PATCH 5/6] typing: various consistency fixes --- beets/dbcore/query.py | 2 +- beets/library/library.py | 7 ++++--- beets/library/models.py | 3 ++- beets/ui/commands/import_/session.py | 2 +- beets/util/__init__.py | 15 +++++---------- beetsplug/autobpm.py | 4 +++- beetsplug/random.py | 24 +++++++++++++----------- beetsplug/smartplaylist.py | 4 ++-- 8 files changed, 31 insertions(+), 30 deletions(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index bd53099eb2..c044e53cdf 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -50,7 +50,7 @@ class InvalidQueryError(ParsingError): def __init__( self, query: str | Sequence[str] | Query | None, explanation: Exception ) -> None: - if isinstance(query, list): + if isinstance(query, Sequence) and not isinstance(query, str): query = " ".join(query) message = f"'{query}': {explanation}" super().__init__(message) diff --git a/beets/library/library.py b/beets/library/library.py index a3613116ec..8a86a2e9ec 100644 --- a/beets/library/library.py +++ b/beets/library/library.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from collections.abc import Sequence from contextlib import contextmanager from functools import cached_property from pathlib import Path @@ -21,7 +22,7 @@ from .queries import parse_query_parts, parse_query_string if TYPE_CHECKING: - from collections.abc import Iterator, Sequence + from collections.abc import Iterator from beets.dbcore.sort import Sort from beets.util import PathLike, Replacements @@ -103,7 +104,7 @@ def add(self, obj: LibModel) -> int | None: self._memotable = {} return obj.id - def add_album(self, items: list[Item]) -> Album: + def add_album(self, items: Sequence[Item]) -> Album: """Create a new album consisting of a list of items. The items are added to the database if they don't yet have an @@ -156,7 +157,7 @@ def _fetch( parsed_query, parsed_sort = parse_query_string( query, model_cls ) - elif isinstance(query, (list, tuple)): + elif isinstance(query, Sequence): parsed_query, parsed_sort = parse_query_parts( query, model_cls ) diff --git a/beets/library/models.py b/beets/library/models.py index 179cd083c7..6829f1a429 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -1411,7 +1411,8 @@ def tmpl_aunique( if memoval is not None: return memoval - album: Album = self.lib.get_album(album_id) # type: ignore[assignment] + if not (album := self.lib.get_album(album_id)): + return "" return self._tmpl_unique( "aunique", diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index 67776ebf92..59d4b24aa5 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -297,7 +297,7 @@ def _get_choices(self, task: ImportTask) -> list[PromptChoice]: return choices + extra_choices -def summarize_items(items: list[Item], singleton: bool) -> str: +def summarize_items(items: Sequence[Item], singleton: bool) -> str: """Produces a brief summary line describing a set of items. Used for manually resolving duplicates during import. diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 17d6652137..993b25ff0a 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -404,19 +404,14 @@ def displayable_path( path: PathLike | Iterable[PathLike], separator: str = "; " ) -> str: """Attempts to decode a bytestring path to a unicode object for the - purpose of displaying it to the user. If the `path` argument is a - list or a tuple, the elements are joined with `separator`. + purpose of displaying it to the user. If the `path` argument is an + iterable, the elements are joined with `separator`. """ - if isinstance(path, (list, tuple)): - return separator.join(displayable_path(p) for p in path) - if isinstance(path, str): - return path - if not isinstance(path, bytes): - # A non-string object: just get its unicode representation. - return str(path) + if isinstance(path, (Path, str, bytes)): + return os.fsdecode(path) - return os.fsdecode(path) + return separator.join(displayable_path(p) for p in path) def syspath(path: PathLike) -> str: diff --git a/beetsplug/autobpm.py b/beetsplug/autobpm.py index 8df5610a5e..8df19c6915 100644 --- a/beetsplug/autobpm.py +++ b/beetsplug/autobpm.py @@ -12,6 +12,8 @@ from beets.util.deprecation import deprecate_for_user if TYPE_CHECKING: + from collections.abc import Iterable + from beets.importer import ImportTask from beets.library import Item, Library @@ -88,7 +90,7 @@ def imported(self, _, task: ImportTask) -> None: def calculate_bpm( self, - items: list[Item], + items: Iterable[Item], write: bool = False, force: bool = False, quiet: bool = False, diff --git a/beetsplug/random.py b/beetsplug/random.py index fb27d60683..8488fc2f00 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -3,16 +3,18 @@ import random from itertools import groupby, islice from operator import methodcaller -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Protocol, TypeVar from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Iterator from beets.library import LibModel, Library + RandomModel = TypeVar("RandomModel", bound=LibModel) + class RandomCLIOpts(Protocol): album: bool @@ -78,8 +80,8 @@ def commands(self) -> list[Subcommand]: def _equal_chance_permutation( - objs: Iterable[LibModel], field: str -) -> Iterable[LibModel]: + objs: Iterable[RandomModel], field: str +) -> Iterator[RandomModel]: """Generate (lazily) a permutation of the objects where every group with equal values for `field` have an equal chance of appearing in any given position. @@ -102,11 +104,10 @@ def _equal_chance_permutation( del groups[group] -def _take_time(iter_: Iterable[LibModel], secs: float) -> Iterable[LibModel]: - """Return a list containing the first values in `iter`, which should - be Item or Album objects, that add up to the given amount of time in - seconds. - """ +def _take_time( + iter_: Iterable[RandomModel], secs: float +) -> Iterator[RandomModel]: + """Yield objects without exceeding the requested total duration.""" total_time = 0.0 for obj in iter_: length = obj.length @@ -116,12 +117,12 @@ def _take_time(iter_: Iterable[LibModel], secs: float) -> Iterable[LibModel]: def random_objs( - objs: Iterable[LibModel], + objs: Iterable[RandomModel], equal_chance_field: str, number: int = 1, time_minutes: float | None = None, equal_chance: bool = False, -) -> Iterable[LibModel]: +) -> Iterator[RandomModel]: """Get a random subset of items, optionally constrained by time or count. Args: @@ -135,6 +136,7 @@ def random_objs( """ # Permute the objects either in a straightforward way or an # field-balanced way. + perm: Iterable[RandomModel] if equal_chance: perm = _equal_chance_permutation(objs, equal_chance_field) else: diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 7fe075a74d..bbc93235c9 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -224,7 +224,7 @@ def update_cmd( self.update_playlists(lib) def _parse_one_query( - self, playlist: JSONDict, key: str, model_cls: type + self, playlist: JSONDict, key: str, model_cls: type[LibModel] ) -> tuple[PlaylistQuery, Sort | None]: qs = playlist.get(key) if qs is None: @@ -273,7 +273,7 @@ def build_queries(self) -> None: self._unmatched_playlists.add((playlist["name"], q_match, a_match)) - def _matches_query(self, model: Item | Album, query: PlaylistQuery) -> bool: + def _matches_query(self, model: LibModel, query: PlaylistQuery) -> bool: if not query: return False if isinstance(query, (list, tuple)): From 588a6997a95584efcc9a55bb244918f18e1ecc56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 19 Aug 2026 07:30:48 +0100 Subject: [PATCH 6/6] play: test play on import --- test/plugins/test_play.py | 52 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/test/plugins/test_play.py b/test/plugins/test_play.py index b108e5bb93..6d83acbba4 100644 --- a/test/plugins/test_play.py +++ b/test/plugins/test_play.py @@ -7,22 +7,34 @@ import pytest -from beets.test.helper import CleanupModulesMixin, IOMixin, PluginTestCase +from beets.test.helper import ( + AutotagImportTestCase, + BeetsTestCase, + CleanupModulesMixin, + IOMixin, + PluginMixin, + TerminalImportMixin, +) from beets.ui import UserError from beets.util import open_anything from beetsplug.play import PlayPlugin -@patch("beetsplug.play.util.interactive_open") -class PlayPluginTest(IOMixin, CleanupModulesMixin, PluginTestCase): +class PlayPluginMixin(CleanupModulesMixin, PluginMixin): modules = (PlayPlugin.__module__,) plugin = "play" + def setUp(self): + super().setUp() + self.config["play"]["command"] = "echo" + + +@patch("beetsplug.play.util.interactive_open") +class PlayPluginTest(IOMixin, PlayPluginMixin, BeetsTestCase): def setUp(self): super().setUp() self.item = self.add_item(album="a nice älbum", title="aNiceTitle") self.lib.add_album([self.item]) - self.config["play"]["command"] = "echo" def run_and_assert( self, @@ -166,3 +178,35 @@ def test_command_failed(self, open_mock): with pytest.raises(UserError): self.run_command("play", "title:aNiceTitle") + + +class PlayOnImportTest( + TerminalImportMixin, PlayPluginMixin, AutotagImportTestCase +): + def setUp(self): + super().setUp() + self.prepare_album_for_import(1) + self.importer = self.setup_importer() + + def test_play_on_import(self): + self.importer.add_choice("y") + self.importer.add_choice("1") + + playlist_path = self.temp_path / "beetsplug_play" / "playlist.m3u" + playlist_path.parent.mkdir(parents=True) + playlist_path.touch() + playlist_path_bytes = os.fsencode(playlist_path) + + with ( + patch( + "beetsplug.play.get_temp_filename", + side_effect=lambda *_, **__: playlist_path_bytes, + ), + patch("beetsplug.play.subprocess.call") as subprocess_call_mock, + ): + self.importer.run() + + # note that the call has mixed types (str and bytes) + subprocess_call_mock.assert_called_once_with( + ["echo", playlist_path_bytes] + )