Adjust types for fully typed confuse (#6268)

* Update configuration handling to use fully typed confuse API which
will be released in confuse `v2.2.0`.
* Use `Subview`, `.sequence()`, `MappingTemplate`, and typed `OneOf`.
* Replace 'naked' configuration dictionary access with typed
`.get/.as_*` equivalents.
* Add typing annotations and `cached_property` where appropriate. 
* Fix related issues in `discogs`, `fetchart`, `lyrics`, `playlist`,
`smartplaylist`, and `titlecase` plugins.

> [!IMPORTANT]
> Depends on https://github.com/beetbox/confuse/pull/187 being merged
and released (as `v2.2.0`)
This commit is contained in:
Šarūnas Nejus 2026-02-16 12:51:14 +00:00 committed by GitHub
commit 230399fd98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 51 additions and 47 deletions

View file

@ -37,11 +37,11 @@ class IncludeLazyConfig(confuse.LazyConfig):
YAML files specified in an `include` setting.
"""
def read(self, user=True, defaults=True):
def read(self, user: bool = True, defaults: bool = True) -> None:
super().read(user, defaults)
try:
for view in self["include"]:
for view in self["include"].sequence():
self.set_file(view.as_filename())
except confuse.NotFoundError:
pass

View file

@ -37,7 +37,7 @@ from beets.util.deprecation import deprecate_for_maintainers, deprecate_for_user
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Iterator, Sequence
from confuse import ConfigView
from confuse import Subview
from beets.dbcore import Query
from beets.dbcore.db import FieldQueryType
@ -162,7 +162,7 @@ class BeetsPlugin(metaclass=BeetsPluginMeta):
album_template_fields: TFuncMap[Album]
name: str
config: ConfigView
config: Subview
early_import_stages: list[ImportStageFunc]
import_stages: list[ImportStageFunc]

View file

@ -30,7 +30,6 @@ import textwrap
import traceback
from difflib import SequenceMatcher
from functools import cache
from itertools import chain
from typing import TYPE_CHECKING, Any, Literal
import confuse
@ -551,18 +550,20 @@ def get_color_config() -> dict[ColorName, str]:
legacy single-color format. Validates all color names against known codes
and raises an error for any invalid entries.
"""
colors_by_color_name: dict[ColorName, list[str]] = {
k: (v if isinstance(v, list) else LEGACY_COLORS.get(v, [v]))
for k, v in config["ui"]["colors"].flatten().items()
}
if invalid_colors := (
set(chain.from_iterable(colors_by_color_name.values()))
- CODE_BY_COLOR.keys()
):
raise UserError(
f"Invalid color(s) in configuration: {', '.join(invalid_colors)}"
template_dict: dict[ColorName, confuse.OneOf[str | list[str]]] = {
n: confuse.OneOf(
[
confuse.Choice(sorted(LEGACY_COLORS)),
confuse.Sequence(confuse.Choice(sorted(CODE_BY_COLOR))),
]
)
for n in ColorName.__args__ # type: ignore[attr-defined]
}
template = confuse.MappingTemplate(template_dict)
colors_by_color_name = {
k: (v if isinstance(v, list) else LEGACY_COLORS.get(v, [v]))
for k, v in config["ui"]["colors"].get(template).items()
}
return {
n: ";".join(str(CODE_BY_COLOR[c]) for c in colors)

View file

@ -327,7 +327,7 @@ def summarize_items(items, singleton):
return ", ".join(summary_parts)
def _summary_judgment(rec):
def _summary_judgment(rec: Recommendation) -> importer.Action | None:
"""Determines whether a decision should be made without even asking
the user. This occurs in quiet mode and when an action is chosen for
NONE recommendations. Return None if the user should be queried.
@ -335,6 +335,7 @@ def _summary_judgment(rec):
summary judgment is made.
"""
action: importer.Action | None
if config["import"]["quiet"]:
if rec == Recommendation.strong:
return importer.Action.APPLY

View file

@ -355,10 +355,9 @@ class DiscogsPlugin(MetadataSourcePlugin):
style = self.format(result.data.get("styles"))
base_genre = self.format(result.data.get("genres"))
if self.config["append_style_genre"] and style:
genre = self.config["separator"].as_str().join([base_genre, style])
else:
genre = base_genre
genre = base_genre
if self.config["append_style_genre"] and genre is not None and style:
genre += f"{self.config['separator'].as_str()}{style}"
discogs_albumid = self._extract_id(result.data.get("uri"))

View file

@ -288,7 +288,8 @@ class Candidate:
elif check == ImageAction.REFORMAT:
self.path = ArtResizer.shared.reformat(
self.path,
plugin.cover_format,
# TODO: fix this gnarly logic to remove the need for type ignore
plugin.cover_format, # type: ignore[arg-type]
deinterlaced=plugin.deinterlace,
)
@ -1367,7 +1368,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
# allow both pixel and percentage-based margin specifications
self.enforce_ratio = self.config["enforce_ratio"].get(
confuse.OneOf(
confuse.OneOf[bool | str](
[
bool,
confuse.String(pattern=self.PAT_PX),

View file

@ -41,6 +41,7 @@ if TYPE_CHECKING:
import optparse
from collections.abc import Callable
from beets.importer import ImportSession, ImportTask
from beets.library import LibModel
LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY)
@ -178,14 +179,13 @@ class LastGenrePlugin(plugins.BeetsPlugin):
"""A tuple of allowed genre sources. May contain 'track',
'album', or 'artist.'
"""
source = self.config["source"].as_choice(("track", "album", "artist"))
if source == "track":
return "track", "album", "artist"
if source == "album":
return "album", "artist"
if source == "artist":
return ("artist",)
return tuple()
return self.config["source"].as_choice(
{
"track": ("track", "album", "artist"),
"album": ("album", "artist"),
"artist": ("artist",),
}
)
# More canonicalization and general helpers.
@ -603,10 +603,8 @@ class LastGenrePlugin(plugins.BeetsPlugin):
lastgenre_cmd.func = lastgenre_func
return [lastgenre_cmd]
def imported(
self, session: library.Session, task: library.ImportTask
) -> None:
self._process(task.album if task.is_album else task.item, write=False)
def imported(self, _: ImportSession, task: ImportTask) -> None:
self._process(task.album if task.is_album else task.item, write=False) # type: ignore[attr-defined]
def _tags_for(
self,

View file

@ -358,7 +358,7 @@ class LRCLib(Backend):
for group in self.fetch_candidates(artist, title, album, length):
candidates = [evaluate_item(item) for item in group]
if item := self.pick_best_match(candidates):
lyrics = item.get_text(self.config["synced"])
lyrics = item.get_text(self.config["synced"].get(bool))
return lyrics, f"{self.GET_URL}/{item.id}"
return None

View file

@ -69,7 +69,7 @@ class PlaylistQuery(InQuery[bytes]):
relative_to = os.path.dirname(playlist_path)
else:
relative_to = config["relative_to"].as_filename()
relative_to = beets.util.bytestring_path(relative_to)
relative_to_bytes = beets.util.bytestring_path(relative_to)
for line in f:
if line[0] == "#":
@ -78,7 +78,7 @@ class PlaylistQuery(InQuery[bytes]):
paths.append(
beets.util.normpath(
os.path.join(relative_to, line.rstrip())
os.path.join(relative_to_bytes, line.rstrip())
)
)
f.close()

View file

@ -262,8 +262,9 @@ class SmartPlaylistPlugin(BeetsPlugin):
"Updating {} smart playlists...", len(self._matched_playlists)
)
playlist_dir = self.config["playlist_dir"].as_filename()
playlist_dir = bytestring_path(playlist_dir)
playlist_dir = bytestring_path(
self.config["playlist_dir"].as_filename()
)
tpl = self.config["uri_format"].get()
prefix = bytestring_path(self.config["prefix"].as_str())
relative_to = self.config["relative_to"].get()

View file

@ -104,7 +104,7 @@ class TitlecasePlugin(BeetsPlugin):
@cached_property
def replace(self) -> list[tuple[str, str]]:
return self.config["replace"].as_pairs()
return self.config["replace"].as_pairs(default_value="")
@cached_property
def the_artist(self) -> bool:

13
poetry.lock generated
View file

@ -722,18 +722,21 @@ files = [
[[package]]
name = "confuse"
version = "2.1.0"
version = "2.2.0"
description = "Painless YAML config files"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.10"
files = [
{file = "confuse-2.1.0-py3-none-any.whl", hash = "sha256:502be1299aa6bf7c48f7719f56795720c073fb28550c0c7a37394366c9d30316"},
{file = "confuse-2.1.0.tar.gz", hash = "sha256:abb9674a99c7a6efaef84e2fc84403ecd2dd304503073ff76ea18ed4176e218d"},
{file = "confuse-2.2.0-py3-none-any.whl", hash = "sha256:470c6aa1a5008c8d740267f2ad574e3a715b6dd873c1e5f8778b7f7abb954722"},
{file = "confuse-2.2.0.tar.gz", hash = "sha256:35c1b53e81be125f441bee535130559c935917b26aeaa61289010cd1f55c2b9e"},
]
[package.dependencies]
pyyaml = "*"
[package.extras]
docs = ["sphinx (>=7.4.7)", "sphinx-rtd-theme (>=3.0.2)"]
[[package]]
name = "coverage"
version = "7.11.0"
@ -4558,4 +4561,4 @@ web = ["flask", "flask-cors"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<4"
content-hash = "eefe427d3b3b9b871ca6bcd8405e3578a16d660afd7925c14793514f03c96ac6"
content-hash = "9cff39f63616b2654fbf44b006f7eedcae6c1846fbb9f04af82483891b7d77b9"

View file

@ -44,7 +44,7 @@ Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst"
python = ">=3.10,<4"
colorama = { version = "*", markers = "sys_platform == 'win32'" }
confuse = ">=2.1.0"
confuse = ">=2.2.0"
jellyfish = "*"
lap = ">=0.5.12"
mediafile = ">=0.12.0"