Move sanitize_pairs/choices from plugins to util module

This commit is contained in:
Šarūnas Nejus 2025-05-24 15:16:02 +01:00
parent 0f76312f31
commit 509cbdcbe4
No known key found for this signature in database
GPG key ID: DD28F6704DBE3435
6 changed files with 85 additions and 72 deletions

View file

@ -654,66 +654,6 @@ def feat_tokens(for_artist: bool = True) -> str:
) )
def sanitize_choices(
choices: Sequence[str], choices_all: Sequence[str]
) -> list[str]:
"""Clean up a stringlist configuration attribute: keep only choices
elements present in choices_all, remove duplicate elements, expand '*'
wildcard while keeping original stringlist order.
"""
seen: set[str] = set()
others = [x for x in choices_all if x not in choices]
res: list[str] = []
for s in choices:
if s not in seen:
if s in list(choices_all):
res.append(s)
elif s == "*":
res.extend(others)
seen.add(s)
return res
def sanitize_pairs(
pairs: Sequence[tuple[str, str]], pairs_all: Sequence[tuple[str, str]]
) -> list[tuple[str, str]]:
"""Clean up a single-element mapping configuration attribute as returned
by Confuse's `Pairs` template: keep only two-element tuples present in
pairs_all, remove duplicate elements, expand ('str', '*') and ('*', '*')
wildcards while keeping the original order. Note that ('*', '*') and
('*', 'whatever') have the same effect.
For example,
>>> sanitize_pairs(
... [('foo', 'baz bar'), ('key', '*'), ('*', '*')],
... [('foo', 'bar'), ('foo', 'baz'), ('foo', 'foobar'),
... ('key', 'value')]
... )
[('foo', 'baz'), ('foo', 'bar'), ('key', 'value'), ('foo', 'foobar')]
"""
pairs_all = list(pairs_all)
seen: set[tuple[str, str]] = set()
others = [x for x in pairs_all if x not in pairs]
res: list[tuple[str, str]] = []
for k, values in pairs:
for v in values.split():
x = (k, v)
if x in pairs_all:
if x not in seen:
seen.add(x)
res.append(x)
elif k == "*":
new = [o for o in others if o not in seen]
seen.update(new)
res.extend(new)
elif v == "*":
new = [o for o in others if o not in seen and o[0] == k]
seen.update(new)
res.extend(new)
return res
def get_distance( def get_distance(
config: ConfigView, data_source: str, info: AlbumInfo | TrackInfo config: ConfigView, data_source: str, info: AlbumInfo | TrackInfo
) -> Distance: ) -> Distance:

66
beets/util/config.py Normal file
View file

@ -0,0 +1,66 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Collection, Sequence
def sanitize_choices(
choices: Sequence[str], choices_all: Collection[str]
) -> list[str]:
"""Clean up a stringlist configuration attribute: keep only choices
elements present in choices_all, remove duplicate elements, expand '*'
wildcard while keeping original stringlist order.
"""
seen: set[str] = set()
others = [x for x in choices_all if x not in choices]
res: list[str] = []
for s in choices:
if s not in seen:
if s in list(choices_all):
res.append(s)
elif s == "*":
res.extend(others)
seen.add(s)
return res
def sanitize_pairs(
pairs: Sequence[tuple[str, str]], pairs_all: Sequence[tuple[str, str]]
) -> list[tuple[str, str]]:
"""Clean up a single-element mapping configuration attribute as returned
by Confuse's `Pairs` template: keep only two-element tuples present in
pairs_all, remove duplicate elements, expand ('str', '*') and ('*', '*')
wildcards while keeping the original order. Note that ('*', '*') and
('*', 'whatever') have the same effect.
For example,
>>> sanitize_pairs(
... [('foo', 'baz bar'), ('key', '*'), ('*', '*')],
... [('foo', 'bar'), ('foo', 'baz'), ('foo', 'foobar'),
... ('key', 'value')]
... )
[('foo', 'baz'), ('foo', 'bar'), ('key', 'value'), ('foo', 'foobar')]
"""
pairs_all = list(pairs_all)
seen: set[tuple[str, str]] = set()
others = [x for x in pairs_all if x not in pairs]
res: list[tuple[str, str]] = []
for k, values in pairs:
for v in values.split():
x = (k, v)
if x in pairs_all:
if x not in seen:
seen.add(x)
res.append(x)
elif k == "*":
new = [o for o in others if o not in seen]
seen.update(new)
res.extend(new)
elif v == "*":
new = [o for o in others if o not in seen and o[0] == k]
seen.update(new)
res.extend(new)
return res

View file

@ -32,6 +32,7 @@ from mediafile import image_mime_type
from beets import config, importer, plugins, ui, util from beets import config, importer, plugins, ui, util
from beets.util import bytestring_path, get_temp_filename, sorted_walk, syspath from beets.util import bytestring_path, get_temp_filename, sorted_walk, syspath
from beets.util.artresizer import ArtResizer from beets.util.artresizer import ArtResizer
from beets.util.config import sanitize_pairs
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence from collections.abc import Iterable, Iterator, Sequence
@ -1396,7 +1397,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
if s_cls.available(self._log, self.config) if s_cls.available(self._log, self.config)
for c in s_cls.VALID_MATCHING_CRITERIA for c in s_cls.VALID_MATCHING_CRITERIA
] ]
sources = plugins.sanitize_pairs( sources = sanitize_pairs(
self.config["sources"].as_pairs(default_value="*"), self.config["sources"].as_pairs(default_value="*"),
available_sources, available_sources,
) )

View file

@ -39,6 +39,7 @@ from unidecode import unidecode
import beets import beets
from beets import plugins, ui from beets import plugins, ui
from beets.autotag.hooks import string_dist from beets.autotag.hooks import string_dist
from beets.util.config import sanitize_choices
if TYPE_CHECKING: if TYPE_CHECKING:
from logging import Logger from logging import Logger
@ -957,7 +958,7 @@ class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
def backends(self) -> list[Backend]: def backends(self) -> list[Backend]:
user_sources = self.config["sources"].get() user_sources = self.config["sources"].get()
chosen = plugins.sanitize_choices(user_sources, self.BACKEND_BY_NAME) chosen = sanitize_choices(user_sources, self.BACKEND_BY_NAME)
if "google" in chosen and not self.config["google_API_key"].get(): if "google" in chosen and not self.config["google_API_key"].get():
self.warn("Disabling Google source: no API key configured.") self.warn("Disabling Google source: no API key configured.")
chosen.remove("google") chosen.remove("google")

View file

@ -15,7 +15,6 @@
import itertools import itertools
import os import os
import unittest
from unittest.mock import ANY, Mock, patch from unittest.mock import ANY, Mock, patch
import pytest import pytest
@ -215,15 +214,6 @@ class EventsTest(PluginImportTestCase):
] ]
class HelpersTest(unittest.TestCase):
def test_sanitize_choices(self):
assert plugins.sanitize_choices(["A", "Z"], ("A", "B")) == ["A"]
assert plugins.sanitize_choices(["A", "A"], ("A")) == ["A"]
assert plugins.sanitize_choices(
["D", "*", "A"], ("A", "B", "C", "D")
) == ["D", "B", "C", "A"]
class ListenersTest(PluginLoaderTestCase): class ListenersTest(PluginLoaderTestCase):
def test_register(self): def test_register(self):
class DummyPlugin(plugins.BeetsPlugin): class DummyPlugin(plugins.BeetsPlugin):

15
test/util/test_config.py Normal file
View file

@ -0,0 +1,15 @@
import unittest
from beets.util.config import sanitize_choices
class HelpersTest(unittest.TestCase):
def test_sanitize_choices(self):
assert sanitize_choices(["A", "Z"], ("A", "B")) == ["A"]
assert sanitize_choices(["A", "A"], ("A")) == ["A"]
assert sanitize_choices(["D", "*", "A"], ("A", "B", "C", "D")) == [
"D",
"B",
"C",
"A",
]