diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index f1d3f36de..2753bf582 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -95,6 +95,8 @@ fbfdfd54446fab6782ef0629da303f14f0a2ecdf
c490ac5810b70f3cf5fd8649669838e8fdb19f4d
# Importer restructure
9147577b2b19f43ca827e9650261a86fb0450cef
+# Renamed all `action` occurrences with `Action`.
+68acaa6470a04474a2aefe9b1de71ae7fa10b861
# Move functionality under MusicBrainz plugin
529aaac7dced71266c6d69866748a7d044ec20ff
# musicbrainz: reorder methods
@@ -183,3 +185,15 @@ d4fd36a03384b0f71144932833d9c633c1f9545f
0cd20798aa585f162433423f767c047b7474a70e
# Reformat using skip-magic-trailing-comma
adfb73b66dba0fc799880af176ef6f9ac0a7ee68
+# Refactor test_library
+6493d4dd018e08eb5d16d5381ebff47deea98bb1
+# Import everything from parent beets.autotag
+29580d2f00eb02f1ce77ebe3e1435804f7252ba1
+# Add setup fixture to TestHelper
+7bad226c33c2d48b5102cff213c67f16eeb09a7b
+# Fix mbcollection tests
+544ea3af5d85df187e080f407d3aaf84eac4ee63
+# Fix ftintitle tests
+ca8544d4bcab503e1e4c2cd6688655c8211aa7a1
+# Fix autobpm tests
+5804a21620098e8da78b1f4a5f85063033586632
diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py
index 094ed9e9b..84d3f4fe4 100644
--- a/beets/autotag/__init__.py
+++ b/beets/autotag/__init__.py
@@ -21,8 +21,18 @@ from importlib import import_module
# Parts of external interface.
from beets.util.deprecation import deprecate_for_maintainers, deprecate_imports
-from .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch
-from .match import Proposal, Recommendation, tag_album, tag_item
+from .distance import Distance, distance, string_dist, track_distance
+from .hooks import AlbumInfo, AttrDict, Info, TrackInfo, correct_list_fields
+from .match import (
+ AlbumMatch,
+ Match,
+ Proposal,
+ Recommendation,
+ TrackMatch,
+ assign_items,
+ tag_album,
+ tag_item,
+)
def __getattr__(name: str):
@@ -32,18 +42,25 @@ def __getattr__(name: str):
)
return import_module("beets.util").get_most_common_tags
- return deprecate_imports(
- __name__, {"Distance": "beets.autotag.distance"}, name
- )
+ return deprecate_imports(__name__, {}, name)
__all__ = [
"AlbumInfo",
"AlbumMatch",
+ "AttrDict",
+ "Distance",
+ "Info",
+ "Match",
"Proposal",
"Recommendation",
"TrackInfo",
"TrackMatch",
+ "assign_items",
+ "correct_list_fields",
+ "distance",
+ "string_dist",
"tag_album",
"tag_item",
+ "track_distance",
]
diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py
index 5a1806c8b..dc65fc6b0 100644
--- a/beets/autotag/hooks.py
+++ b/beets/autotag/hooks.py
@@ -17,13 +17,12 @@
from __future__ import annotations
from copy import deepcopy
-from dataclasses import dataclass, field
from functools import cached_property
-from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
+from typing import Any, ClassVar, TypeVar
from typing_extensions import Self
-from beets import config, logging, plugins
+from beets import config, logging
from beets.util import cached_classproperty, unique_list
from beets.util.deprecation import (
ALBUM_LEGACY_TO_LIST_FIELD,
@@ -31,13 +30,6 @@ from beets.util.deprecation import (
deprecate_for_maintainers,
)
-if TYPE_CHECKING:
- from collections.abc import Sequence
-
- from beets.library import Album, Item
-
- from .distance import Distance
-
V = TypeVar("V")
JSONDict = dict[str, Any]
@@ -542,161 +534,3 @@ class TrackInfo(Info):
| track.item_data
)
return merged
-
-
-# Structures that compose all the information for a candidate match.
-@dataclass
-class Match:
- """Represent a chosen metadata candidate and its application behavior."""
-
- disambig_fields_key: ClassVar[str]
-
- distance: Distance
- info: Info
-
- def apply_metadata(self) -> None:
- """Apply this match's metadata to its target library objects."""
- raise NotImplementedError
-
- @cached_property
- def type(self) -> str:
- return self.info.type
-
- @cached_property
- def config_from_scratch(self) -> bool:
- return bool(config["import"]["from_scratch"])
-
- def from_scratch(self, override: bool | None) -> bool:
- if override is not None:
- return override
-
- return self.config_from_scratch
-
- @property
- def disambig_fields(self) -> Sequence[str]:
- """Return configured disambiguation fields that exist on this match."""
- chosen_fields = config["match"][self.disambig_fields_key].as_str_seq()
- valid_fields = [f for f in chosen_fields if f in self.info]
- if missing_fields := set(chosen_fields) - set(valid_fields):
- log.warning(
- "Disambiguation string keys {} do not exist.", missing_fields
- )
-
- return valid_fields
-
- @property
- def base_disambig_data(self) -> JSONDict:
- """Return supplemental values used when formatting disambiguation."""
- return {}
-
- @property
- def disambig_string(self) -> str:
- """Build a display string from the candidate's disambiguation fields.
-
- Merges base disambiguation data with instance-specific field values,
- then formats them as a comma-separated string in field definition order.
- """
- data = {
- k: self.info[k] for k in self.disambig_fields
- } | self.base_disambig_data
- return ", ".join(str(data[k]) for k in self.disambig_fields)
-
-
-@dataclass
-class AlbumMatch(Match):
- """Represent an album candidate together with its item-to-track mapping."""
-
- disambig_fields_key = "album_disambig_fields"
-
- info: AlbumInfo
- mapping: dict[Item, TrackInfo]
- extra_items: list[Item] = field(default_factory=list)
- extra_tracks: list[TrackInfo] = field(default_factory=list)
-
- def __post_init__(self) -> None:
- """Notify listeners when an album candidate has been matched."""
- plugins.send("album_matched", match=self)
-
- @property
- def item_info_pairs(self) -> list[tuple[Item, TrackInfo]]:
- """Return matched items together with their selected track metadata."""
- return list(self.mapping.items())
-
- @property
- def items(self) -> list[Item]:
- """Return the items that participate in this album match."""
- return [i for i, _ in self.item_info_pairs]
-
- @property
- def base_disambig_data(self) -> JSONDict:
- """Return album-specific values used in disambiguation displays."""
- return {
- "media": (
- f"{mediums}x{self.info.media}"
- if (mediums := self.info.mediums) and mediums > 1
- else self.info.media
- )
- }
-
- @property
- def merged_pairs(self) -> list[tuple[Item, JSONDict]]:
- """Generate item-data pairs with album-level fallback values."""
- return [
- (i, ti.merge_with_album(self.info))
- for i, ti in self.item_info_pairs
- ]
-
- def apply_metadata(self, from_scratch: bool | None = None) -> None:
- """Apply metadata to each of the items.
-
- If ``from_scratch`` is provided, its value determines whether the
- items existing metadata are cleared before applying new metadata.
- Otherwise, the configured ``from_scratch`` setting is used.
- """
- for item, data in self.merged_pairs:
- if self.from_scratch(from_scratch):
- item.clear()
-
- item.update(data)
-
- def apply_album_metadata(self, album: Album) -> None:
- """Apply album-level metadata to the Album object."""
- album.update(self.info.item_data)
-
-
-@dataclass
-class TrackMatch(Match):
- """Represent a singleton candidate and the item it updates."""
-
- disambig_fields_key = "singleton_disambig_fields"
-
- info: TrackInfo
- item: Item
-
- @property
- def base_disambig_data(self) -> JSONDict:
- """Return singleton-specific values used in disambiguation displays."""
- return {
- "index": f"Index {self.info.index}",
- "track_alt": f"Track {self.info.track_alt}",
- "album": (
- f"[{self.info.album}]"
- if (
- config["import"]["singleton_album_disambig"].get()
- and self.info.album
- )
- else ""
- ),
- }
-
- def apply_metadata(self, from_scratch: bool | None = None) -> None:
- """Apply metadata to the item.
-
- If ``from_scratch`` is provided, its value determines whether the
- item's existing metadata is cleared before applying new metadata.
- Otherwise, the configured ``from_scratch`` setting is used.
- """
- if self.from_scratch(from_scratch):
- self.item.clear()
-
- self.item.update(self.info.item_data)
diff --git a/beets/autotag/match.py b/beets/autotag/match.py
index 9cda3884d..f8ccbb22a 100644
--- a/beets/autotag/match.py
+++ b/beets/autotag/match.py
@@ -18,28 +18,30 @@ releases and tracks.
from __future__ import annotations
+from dataclasses import dataclass, field
from enum import IntEnum
-from typing import TYPE_CHECKING, NamedTuple, TypeVar
+from functools import cached_property
+from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, TypeVar
import lap
import numpy as np
-from beets import config, logging, metadata_plugins
+from beets import config, logging, metadata_plugins, plugins
from beets.util import get_most_common_tags
from .distance import VA_ARTISTS, distance, track_distance
-from .hooks import AlbumMatch, Info, TrackMatch
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
- from beets.library import Item
+ from beets.library import Album, Item
- from .hooks import AlbumInfo, TrackInfo
+ from .distance import Distance
+ from .hooks import AlbumInfo, Info, TrackInfo
-
-AnyMatch = TypeVar("AnyMatch", TrackMatch, AlbumMatch)
-Candidates = dict[Info.Identifier, AnyMatch]
+ JSONDict = dict[str, Any]
+ AnyMatch = TypeVar("AnyMatch", "TrackMatch", "AlbumMatch")
+ Candidates = dict[Info.Identifier, AnyMatch]
# Global logger.
log = logging.getLogger("beets")
@@ -59,16 +61,6 @@ class Recommendation(IntEnum):
strong = 3
-# A structure for holding a set of possible matches to choose between. This
-# consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo
-# objects) and a recommendation value.
-
-
-class Proposal(NamedTuple):
- candidates: Sequence[AlbumMatch | TrackMatch]
- recommendation: Recommendation
-
-
# Primary matching functionality.
@@ -103,6 +95,174 @@ def assign_items(
return list(mapping.items()), extra_items, extra_tracks
+# Structures that compose all the information for a candidate match.
+@dataclass
+class Match:
+ """Represent a chosen metadata candidate and its application behavior."""
+
+ disambig_fields_key: ClassVar[str]
+
+ distance: Distance
+ info: Info
+
+ def apply_metadata(self) -> None:
+ """Apply this match's metadata to its target library objects."""
+ raise NotImplementedError
+
+ @cached_property
+ def type(self) -> str:
+ return self.info.type
+
+ @cached_property
+ def config_from_scratch(self) -> bool:
+ return bool(config["import"]["from_scratch"])
+
+ def from_scratch(self, override: bool | None) -> bool:
+ if override is not None:
+ return override
+
+ return self.config_from_scratch
+
+ @property
+ def disambig_fields(self) -> Sequence[str]:
+ """Return configured disambiguation fields that exist on this match."""
+ chosen_fields = config["match"][self.disambig_fields_key].as_str_seq()
+ valid_fields = [f for f in chosen_fields if f in self.info]
+ if missing_fields := set(chosen_fields) - set(valid_fields):
+ log.warning(
+ "Disambiguation string keys {} do not exist.", missing_fields
+ )
+
+ return valid_fields
+
+ @property
+ def base_disambig_data(self) -> JSONDict:
+ """Return supplemental values used when formatting disambiguation."""
+ return {}
+
+ @property
+ def disambig_string(self) -> str:
+ """Build a display string from the candidate's disambiguation fields.
+
+ Merges base disambiguation data with instance-specific field values,
+ then formats them as a comma-separated string in field definition order.
+ """
+ data = {
+ k: self.info[k] for k in self.disambig_fields
+ } | self.base_disambig_data
+ return ", ".join(str(data[k]) for k in self.disambig_fields)
+
+
+@dataclass
+class AlbumMatch(Match):
+ """Represent an album candidate together with its item-to-track mapping."""
+
+ disambig_fields_key = "album_disambig_fields"
+
+ info: AlbumInfo
+ mapping: dict[Item, TrackInfo]
+ extra_items: list[Item] = field(default_factory=list)
+ extra_tracks: list[TrackInfo] = field(default_factory=list)
+
+ def __post_init__(self) -> None:
+ """Notify listeners when an album candidate has been matched."""
+ plugins.send("album_matched", match=self)
+
+ @property
+ def item_info_pairs(self) -> list[tuple[Item, TrackInfo]]:
+ """Return matched items together with their selected track metadata."""
+ return list(self.mapping.items())
+
+ @property
+ def items(self) -> list[Item]:
+ """Return the items that participate in this album match."""
+ return [i for i, _ in self.item_info_pairs]
+
+ @property
+ def base_disambig_data(self) -> JSONDict:
+ """Return album-specific values used in disambiguation displays."""
+ return {
+ "media": (
+ f"{mediums}x{self.info.media}"
+ if (mediums := self.info.mediums) and mediums > 1
+ else self.info.media
+ )
+ }
+
+ @property
+ def merged_pairs(self) -> list[tuple[Item, JSONDict]]:
+ """Generate item-data pairs with album-level fallback values."""
+ return [
+ (i, ti.merge_with_album(self.info))
+ for i, ti in self.item_info_pairs
+ ]
+
+ def apply_metadata(self, from_scratch: bool | None = None) -> None:
+ """Apply metadata to each of the items.
+
+ If ``from_scratch`` is provided, its value determines whether the
+ items existing metadata are cleared before applying new metadata.
+ Otherwise, the configured ``from_scratch`` setting is used.
+ """
+ for item, data in self.merged_pairs:
+ if self.from_scratch(from_scratch):
+ item.clear()
+
+ item.update(data)
+
+ def apply_album_metadata(self, album: Album) -> None:
+ """Apply album-level metadata to the Album object."""
+ album.update(self.info.item_data)
+
+
+@dataclass
+class TrackMatch(Match):
+ """Represent a singleton candidate and the item it updates."""
+
+ disambig_fields_key = "singleton_disambig_fields"
+
+ info: TrackInfo
+ item: Item
+
+ @property
+ def base_disambig_data(self) -> JSONDict:
+ """Return singleton-specific values used in disambiguation displays."""
+ return {
+ "index": f"Index {self.info.index}",
+ "track_alt": f"Track {self.info.track_alt}",
+ "album": (
+ f"[{self.info.album}]"
+ if (
+ config["import"]["singleton_album_disambig"].get()
+ and self.info.album
+ )
+ else ""
+ ),
+ }
+
+ def apply_metadata(self, from_scratch: bool | None = None) -> None:
+ """Apply metadata to the item.
+
+ If ``from_scratch`` is provided, its value determines whether the
+ item's existing metadata is cleared before applying new metadata.
+ Otherwise, the configured ``from_scratch`` setting is used.
+ """
+ if self.from_scratch(from_scratch):
+ self.item.clear()
+
+ self.item.update(self.info.item_data)
+
+
+# A structure for holding a set of possible matches to choose between. This
+# consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo
+# objects) and a recommendation value.
+
+
+class Proposal(NamedTuple):
+ candidates: Sequence[AlbumMatch | TrackMatch]
+ recommendation: Recommendation
+
+
def match_by_id(album_id: str | None, consensus: bool) -> Iterable[AlbumInfo]:
"""Return album candidates for the given album id.
diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py
index cbc12b62b..e31d581bb 100644
--- a/beets/importer/tasks.py
+++ b/beets/importer/tasks.py
@@ -28,8 +28,7 @@ from typing import TYPE_CHECKING, Any
import mediafile
from beets import config, library, plugins, util
-from beets.autotag.hooks import AlbumMatch
-from beets.autotag.match import tag_album, tag_item
+from beets.autotag import AlbumMatch, tag_album, tag_item
from beets.dbcore.query import PathQuery
from beets.util import extension
from beets.util.extension import remux_mpeglayer3_wav
@@ -39,8 +38,7 @@ from .state import ImportState
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
- from beets.autotag.hooks import TrackMatch
- from beets.autotag.match import Recommendation
+ from beets.autotag import Recommendation, TrackMatch
from .session import ImportSession
diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py
index 0bac956a0..7bcd30399 100644
--- a/beets/metadata_plugins.py
+++ b/beets/metadata_plugins.py
@@ -36,7 +36,8 @@ QueryType = Literal["album", "track"]
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Iterator, Sequence
- from .autotag.hooks import AlbumInfo, Item, TrackInfo
+ from .autotag import AlbumInfo, TrackInfo
+ from .library.models import Item
# Global logger.
log = logging.getLogger("beets")
diff --git a/beets/test/helper.py b/beets/test/helper.py
index cb66917ad..0f7883948 100644
--- a/beets/test/helper.py
+++ b/beets/test/helper.py
@@ -46,7 +46,7 @@ from mediafile import Image, MediaFile
import beets
import beets.plugins
from beets import importer, logging, util
-from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.autotag import AlbumInfo, TrackInfo
from beets.importer import ImportSession
from beets.library import Item, Library
from beets.test import _common
@@ -162,6 +162,14 @@ class TestHelper(RunMixin, ConfigMixin):
fixtures.
"""
+ @pytest.fixture(autouse=True)
+ def setup(self):
+ self.setup_beets()
+ try:
+ yield
+ finally:
+ self.teardown_beets()
+
lib: Library
resource_path = Path(os.fsdecode(_common.RSRC)) / "full.mp3"
@@ -407,27 +415,9 @@ class BeetsTestCase(unittest.TestCase, TestHelper):
completes. Also provides some additional assertion methods, a
temporary directory, and a DummyIO.
- DEPRECATED: Use pytest + PytestTestHelper instead.
+ DEPRECATED: Use TestHelper instead.
"""
- def setUp(self):
- self.setup_beets()
-
- def tearDown(self):
- self.teardown_beets()
-
-
-class PytestTestHelper(TestHelper):
- """Same as the BeetsTestCase unittest setup but for pytest."""
-
- @pytest.fixture(autouse=True)
- def setup(self):
- self.setup_beets()
- try:
- yield
- finally:
- self.teardown_beets()
-
class ItemInDBTestCase(BeetsTestCase):
"""A test case that includes an in-memory library object (`lib`) and
@@ -488,13 +478,13 @@ class PluginMixin(ConfigMixin):
class PluginTestCase(PluginMixin, BeetsTestCase):
"""
- DEPRECATED: Use pytest + PytestPluginTestHelper instead.
+ DEPRECATED: Use PluginTestHelper instead.
"""
pass
-class PytestPluginTestHelper(PluginMixin, PytestTestHelper):
+class PluginTestHelper(PluginMixin, TestHelper):
"""Helper mixin for pytest-based plugin tests.
This mixin provides the standard beets test setup and automatically
@@ -502,7 +492,7 @@ class PytestPluginTestHelper(PluginMixin, PytestTestHelper):
.. code-block:: python
- class TestMyPlugin(PytestPluginTestHelper):
+ class TestMyPlugin(PluginTestHelper):
plugin: ClassVar[str] = "myplugin"
"""
diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py
index b4903adae..187da0bda 100644
--- a/beets/ui/commands/import_/display.py
+++ b/beets/ui/commands/import_/display.py
@@ -7,7 +7,7 @@ from functools import cached_property
from typing import TYPE_CHECKING
from beets import config, ui
-from beets.autotag.hooks import TrackInfo
+from beets.autotag import TrackInfo
from beets.util import displayable_path
from beets.util.color import colorize
from beets.util.diff import colordiff
@@ -17,7 +17,7 @@ from beets.util.units import human_seconds_short
if TYPE_CHECKING:
import confuse
- from beets.autotag.hooks import AlbumMatch, Match, TrackMatch
+ from beets.autotag import AlbumMatch, Match, TrackMatch
from beets.library.models import Item
from beets.util.color import ColorName
diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py
index de8d09a18..5806b1b0f 100644
--- a/beets/ui/commands/import_/session.py
+++ b/beets/ui/commands/import_/session.py
@@ -4,8 +4,14 @@ from collections import Counter
from itertools import chain
from beets import config, importer, logging, plugins, ui
-from beets.autotag.hooks import AlbumMatch, TrackMatch
-from beets.autotag.match import Proposal, Recommendation, tag_album, tag_item
+from beets.autotag import (
+ AlbumMatch,
+ Proposal,
+ Recommendation,
+ TrackMatch,
+ tag_album,
+ tag_item,
+)
from beets.util import PromptChoice, displayable_path
from beets.util.color import colorize
from beets.util.units import human_bytes, human_seconds_short
diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py
index 92e8839fd..c3506f7bf 100644
--- a/beetsplug/beatport.py
+++ b/beetsplug/beatport.py
@@ -32,7 +32,7 @@ from requests_oauthlib.oauth1_session import (
import beets
import beets.ui
from beets import config
-from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.autotag import AlbumInfo, TrackInfo
from beets.exceptions import UserError
from beets.metadata_plugins import MetadataSourcePlugin
from beets.util import unique_list
diff --git a/beetsplug/bench.py b/beetsplug/bench.py
index cc29b14cb..9df32af43 100644
--- a/beetsplug/bench.py
+++ b/beetsplug/bench.py
@@ -18,7 +18,7 @@ import cProfile
import timeit
from beets import importer, plugins, ui
-from beets.autotag.match import tag_album
+from beets.autotag import tag_album
from beets.plugins import BeetsPlugin
from beets.util.functemplate import Template
from beets.util.pathformats import PF_KEY_DEFAULT
diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py
index 6583e3fe8..c13ade036 100644
--- a/beetsplug/bpsync.py
+++ b/beetsplug/bpsync.py
@@ -15,8 +15,7 @@
"""Update library's tags using Beatport."""
from beets import library, ui, util
-from beets.autotag.distance import Distance
-from beets.autotag.hooks import AlbumMatch, TrackMatch
+from beets.autotag import AlbumMatch, Distance, TrackMatch
from beets.plugins import BeetsPlugin, apply_item_changes
from beets.util.deprecation import deprecate_for_user
diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py
index eb001d90e..0cd280b0a 100644
--- a/beetsplug/chroma.py
+++ b/beetsplug/chroma.py
@@ -28,7 +28,7 @@ import acoustid
import confuse
from beets import config, ui, util
-from beets.autotag.distance import Distance
+from beets.autotag import Distance
from beets.exceptions import UserError
from beets.metadata_plugins import MetadataSourcePlugin, get_metadata_source
from beets.util.color import colorize
@@ -36,7 +36,7 @@ from beets.util.color import colorize
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
- from beets.autotag.hooks import TrackInfo
+ from beets.autotag import TrackInfo
from beets.library.models import Item
from beetsplug.musicbrainz import MusicBrainzPlugin
diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py
index 209ae035c..49b570aaa 100644
--- a/beetsplug/deezer.py
+++ b/beetsplug/deezer.py
@@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, ClassVar
import requests
from beets import config, ui
-from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.autotag import AlbumInfo, TrackInfo
from beets.dbcore import types
from beets.exceptions import UserError
from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin
diff --git a/beetsplug/discogs/__init__.py b/beetsplug/discogs/__init__.py
index a1c95269b..fcf4c090e 100644
--- a/beetsplug/discogs/__init__.py
+++ b/beetsplug/discogs/__init__.py
@@ -37,8 +37,7 @@ from requests.exceptions import ConnectionError
import beets
import beets.ui
from beets import config, util
-from beets.autotag.distance import string_dist
-from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.autotag import AlbumInfo, TrackInfo, string_dist
from beets.exceptions import UserError
from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin
diff --git a/beetsplug/discogs/states.py b/beetsplug/discogs/states.py
index 9e1405a3f..9324b5024 100644
--- a/beetsplug/discogs/states.py
+++ b/beetsplug/discogs/states.py
@@ -29,7 +29,7 @@ from .types import ArtistInfo
if TYPE_CHECKING:
from confuse import ConfigView
- from beets.autotag.hooks import TrackInfo
+ from beets.autotag import TrackInfo
from . import DiscogsPlugin
from .types import Artist, Track, TracklistInfo
diff --git a/beetsplug/discogs/types.py b/beetsplug/discogs/types.py
index cbd95e901..14ef138b9 100644
--- a/beetsplug/discogs/types.py
+++ b/beetsplug/discogs/types.py
@@ -19,7 +19,7 @@ from typing import TYPE_CHECKING
from typing_extensions import NotRequired, TypedDict
if TYPE_CHECKING:
- from beets.autotag.hooks import TrackInfo
+ from beets.autotag import TrackInfo
class ReleaseFormat(TypedDict):
diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py
index 8dbfed256..9aec77e86 100644
--- a/beetsplug/lyrics.py
+++ b/beetsplug/lyrics.py
@@ -34,7 +34,7 @@ from bs4 import BeautifulSoup
from unidecode import unidecode
from beets import plugins, ui
-from beets.autotag.distance import string_dist
+from beets.autotag import string_dist
from beets.dbcore import types
from beets.dbcore.query import FalseQuery
from beets.library import Item, parse_query_string
@@ -44,7 +44,7 @@ from beets.util.lyrics import INSTRUMENTAL_LYRICS, Lyrics
from ._utils.requests import HTTPNotFoundError, RequestHandler
if TYPE_CHECKING:
- from collections.abc import Iterable, Iterator
+ from collections.abc import Callable, Iterable, Iterator
import confuse
@@ -60,6 +60,8 @@ if TYPE_CHECKING:
TranslatorAPI,
)
+ HtmlTransformer = Callable[[str], str]
+
class CaptchaError(requests.exceptions.HTTPError):
def __init__(self, *args, **kwargs) -> None:
@@ -444,11 +446,20 @@ class MusiXmatch(Backend):
return Lyrics(lyrics, self.__class__.name, url)
+def apply_transforms(html: str, methods: Iterable[HtmlTransformer]) -> str:
+ for method in methods:
+ html = method(html)
+
+ return html
+
+
class Html:
collapse_space = partial(re.compile(r"(^| ) +", re.M).sub, r"\1")
expand_br = partial(re.compile(r"\s* ]*>\s*", re.I).sub, "\n")
#: two newlines between paragraphs on the same line (musica, letras.mus.br)
- merge_blocks = partial(re.compile(r"(?)
]*>").sub, "\n\n")
+ merge_blocks = partial(
+ re.compile(r"(?)(
]*>)+", re.S).sub, "\n\n"
+ )
#: a single new line between paragraphs on separate lines
#: (paroles.net, sweetslyrics.com, lacoccinelle.net)
merge_lines = partial(re.compile(r"
\s+]*>(?!___)").sub, "\n")
@@ -458,6 +469,26 @@ class Html:
)
#: remove Google Ads tags (musica.com)
remove_aside = partial(re.compile("").sub, "")
+ #: remove inline script blocks that split lyrics paragraphs
+ remove_scripts = partial(
+ re.compile(r"]*>", re.I | re.S).sub, ""
+ )
+ #: remove comments that split lyrics paragraphs
+ remove_comments = partial(re.compile(r"", re.S).sub, "")
+ #: remove title-only paragraph from the musica.com lyrics block
+ remove_musica_title = partial(
+ re.compile(
+ r"(]*>.*?)"
+ r"
\s*[^<]+ \s*
",
+ re.I | re.S,
+ ).sub,
+ r"\1",
+ )
+ #: remove non-lyrics explanation blocks (musica.com)
+ remove_significado = partial(
+ re.compile(r"
]*>.*", re.I | re.S).sub,
+ "",
+ )
#: remove adslot-Content_1 div from the lyrics text (paroles.net)
remove_adslot = partial(
re.compile(r"\n
[^\n]+-- Content_\d+ --.*?\n
", re.S).sub, "\n"
@@ -470,22 +501,32 @@ class Html:
@classmethod
def normalize_space(cls, text: str) -> str:
text = unescape(text).replace("\r", "").replace("\xa0", " ")
- return cls.collapse_space(cls.expand_br(text))
+ return apply_transforms(text, [cls.collapse_space, cls.expand_br])
@classmethod
def remove_ads(cls, text: str) -> str:
- return cls.remove_adslot(cls.remove_aside(text))
+ return apply_transforms(text, [cls.remove_aside, cls.remove_adslot])
@classmethod
def merge_paragraphs(cls, text: str) -> str:
- return cls.merge_blocks(cls.merge_lines(cls.remove_empty_tags(text)))
+ return apply_transforms(
+ text, [cls.remove_empty_tags, cls.merge_lines, cls.merge_blocks]
+ )
class SoupMixin:
@classmethod
def pre_process_html(cls, html: str) -> str:
"""Pre-process the HTML content before scraping."""
- return Html.normalize_space(html)
+ return apply_transforms(
+ html,
+ [
+ Html.normalize_space,
+ Html.remove_significado,
+ Html.remove_scripts,
+ Html.remove_musica_title,
+ ],
+ )
@classmethod
def get_soup(cls, html: str) -> BeautifulSoup:
@@ -679,8 +720,16 @@ class Google(SearchBackend):
@classmethod
def pre_process_html(cls, html: str) -> str:
"""Pre-process the HTML content before scraping."""
- html = Html.remove_ads(super().pre_process_html(html))
- return Html.remove_formatting(Html.merge_paragraphs(html))
+ return apply_transforms(
+ html,
+ [
+ super().pre_process_html,
+ Html.remove_ads,
+ Html.remove_comments,
+ Html.merge_paragraphs,
+ Html.remove_formatting,
+ ],
+ )
def get_text(self, *args, **kwargs) -> str:
"""Handle an error so that we can continue with the next URL."""
diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py
index 30bb9dd56..5b0ade385 100644
--- a/beetsplug/mbpseudo.py
+++ b/beetsplug/mbpseudo.py
@@ -25,9 +25,7 @@ import mediafile
from typing_extensions import override
from beets import config
-from beets.autotag.distance import distance
-from beets.autotag.hooks import AlbumInfo
-from beets.autotag.match import assign_items
+from beets.autotag import AlbumInfo, assign_items, distance
from beets.plugins import find_plugins
from beets.util.id_extractors import extract_release_id
from beetsplug.musicbrainz import (
@@ -39,8 +37,7 @@ from beetsplug.musicbrainz import (
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
- from beets.autotag.distance import Distance
- from beets.autotag.hooks import AlbumMatch
+ from beets.autotag import AlbumMatch, Distance
from beets.library import Item
from ._utils.musicbrainz import (
diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py
index 2d97c2bc9..7136f4c29 100644
--- a/beetsplug/mbsubmit.py
+++ b/beetsplug/mbsubmit.py
@@ -24,7 +24,7 @@ implemented by MusicBrainz yet.
import subprocess
from beets import ui
-from beets.autotag.match import Recommendation
+from beets.autotag import Recommendation
from beets.plugins import BeetsPlugin
from beets.util import PromptChoice, displayable_path
from beetsplug.info import print_data
diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py
index d61679a39..609b43829 100644
--- a/beetsplug/mbsync.py
+++ b/beetsplug/mbsync.py
@@ -17,8 +17,7 @@
from collections import defaultdict
from beets import library, metadata_plugins, ui, util
-from beets.autotag.distance import Distance
-from beets.autotag.hooks import AlbumMatch, TrackMatch
+from beets.autotag import AlbumMatch, Distance, TrackMatch
from beets.plugins import BeetsPlugin, apply_item_changes
diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py
index 65af1c27d..ea236ffde 100644
--- a/beetsplug/musicbrainz.py
+++ b/beetsplug/musicbrainz.py
@@ -27,7 +27,7 @@ from confuse.exceptions import NotFoundError
from typing_extensions import NotRequired
from beets import config, plugins, util
-from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.autotag import AlbumInfo, TrackInfo
from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin
from beets.util.deprecation import deprecate_for_user
from beets.util.id_extractors import extract_release_id
diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py
index b3a933336..6a5bbdc37 100644
--- a/beetsplug/spotify.py
+++ b/beetsplug/spotify.py
@@ -34,7 +34,7 @@ import confuse
import requests
from beets import ui
-from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.autotag import AlbumInfo, TrackInfo
from beets.dbcore import types
from beets.exceptions import UserError
from beets.library import Library
diff --git a/beetsplug/tidal/__init__.py b/beetsplug/tidal/__init__.py
index a617050b2..214722603 100644
--- a/beetsplug/tidal/__init__.py
+++ b/beetsplug/tidal/__init__.py
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, overload
import confuse
from beets import ui
-from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.autotag import AlbumInfo, TrackInfo
from beets.exceptions import UserError
from beets.logging import getLogger
from beets.metadata_plugins import MetadataSourcePlugin
diff --git a/beetsplug/titlecase.py b/beetsplug/titlecase.py
index 634f5fe4d..c7062d0d9 100644
--- a/beetsplug/titlecase.py
+++ b/beetsplug/titlecase.py
@@ -25,11 +25,11 @@ from typing import TYPE_CHECKING, TypedDict
from titlecase import titlecase
from beets import ui
-from beets.autotag.hooks import AlbumInfo
+from beets.autotag import AlbumInfo
from beets.plugins import BeetsPlugin
if TYPE_CHECKING:
- from beets.autotag.hooks import Info
+ from beets.autotag import Info
from beets.importer import ImportSession, ImportTask
from beets.library import Item
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 270090739..eb2178e30 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -69,10 +69,15 @@ Bug fixes
- :doc:`plugins/fetchart`: Catch ``OSError`` in ``_set_art`` so that permission
errors (e.g. a file locked by another process) are logged as warnings instead
of crashing beets. :bug:`6193`
+- :doc:`plugins/lyrics`: Improve Musica.com lyric scraping so fetched lyrics no
+ longer omit the opening verse or include non-lyric page content.
-..
- For plugin developers
- ~~~~~~~~~~~~~~~~~~~~~
+For plugin developers
+~~~~~~~~~~~~~~~~~~~~~
+
+- Plugin authors can import all autotagger helpers directly from
+ ``beets.autotag``, including match classes, distance helpers, and
+ ``assign_items``, without relying on lower-level autotag modules.
Other changes
~~~~~~~~~~~~~
diff --git a/docs/dev/plugins/autotagger.rst b/docs/dev/plugins/autotagger.rst
index dc0e41be3..5ab2891c9 100644
--- a/docs/dev/plugins/autotagger.rst
+++ b/docs/dev/plugins/autotagger.rst
@@ -29,7 +29,7 @@ Here`s a minimal example:
# beetsplug/myawesomeplugin.py
from typing import Sequence
- from beets.autotag.hooks import Item
+ from beets.library import Item
from beets.metadata_plugins import MetadataSourcePlugin
diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py
index b5226d41d..11b6d29d5 100644
--- a/test/autotag/test_distance.py
+++ b/test/autotag/test_distance.py
@@ -2,13 +2,14 @@ import re
import pytest
-from beets.autotag.distance import (
+from beets.autotag import (
+ AlbumInfo,
Distance,
+ TrackInfo,
distance,
string_dist,
track_distance,
)
-from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.library import Item
from beets.metadata_plugins import MetadataSourcePlugin, get_penalty
from beets.plugins import BeetsPlugin
diff --git a/test/autotag/test_hooks.py b/test/autotag/test_hooks.py
index 9d2d10db9..51b2ab470 100644
--- a/test/autotag/test_hooks.py
+++ b/test/autotag/test_hooks.py
@@ -20,11 +20,11 @@ from functools import cached_property
import pytest
-from beets.autotag.distance import Distance
-from beets.autotag.hooks import (
+from beets.autotag import (
AlbumInfo,
AlbumMatch,
AttrDict,
+ Distance,
TrackInfo,
TrackMatch,
correct_list_fields,
diff --git a/test/autotag/test_match.py b/test/autotag/test_match.py
index daa6fb9db..302df212f 100644
--- a/test/autotag/test_match.py
+++ b/test/autotag/test_match.py
@@ -3,8 +3,13 @@ from typing import ClassVar
import pytest
from beets import metadata_plugins
-from beets.autotag.hooks import AlbumInfo, TrackInfo
-from beets.autotag.match import assign_items, tag_album, tag_item
+from beets.autotag import (
+ AlbumInfo,
+ TrackInfo,
+ assign_items,
+ tag_album,
+ tag_item,
+)
from beets.library import Item
diff --git a/test/conftest.py b/test/conftest.py
index ec202a071..986882f9f 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -5,7 +5,7 @@ from functools import cache
import pytest
-from beets.autotag.distance import Distance
+from beets.autotag import Distance
from beets.dbcore.query import Query
from beets.test._common import DummyIO
from beets.test.helper import ConfigMixin
diff --git a/test/plugins/lyrics_pages.py b/test/plugins/lyrics_pages.py
index df57d05b2..61cd13ef8 100644
--- a/test/plugins/lyrics_pages.py
+++ b/test/plugins/lyrics_pages.py
@@ -426,6 +426,11 @@ lyrics_pages = [
LyricsPage.make(
"https://www.musica.com/letras.asp?letra=59862",
"""
+ Lady Madonna, children at your feet
+ Wonder how you manage to make ends meet
+ Who finds the money when you pay the rent?
+ Did you think that money was heaven sent?
+
Friday night arrives without a suitcase
Sunday morning creeping like a nun
Monday's child has learned to tie his bootlace
diff --git a/test/plugins/test_art.py b/test/plugins/test_art.py
index f71c2aabf..9831abcbc 100644
--- a/test/plugins/test_art.py
+++ b/test/plugins/test_art.py
@@ -28,11 +28,10 @@ from requests_mock import ANY as ANYREEQUEST
from requests_mock.exceptions import NoMockAddress
from beets import config, importer, logging, util
-from beets.autotag.distance import Distance
-from beets.autotag.hooks import AlbumInfo, AlbumMatch
+from beets.autotag import AlbumInfo, AlbumMatch, Distance
from beets.library import Album
from beets.test import _common
-from beets.test.helper import FetchImageHelper, PytestTestHelper
+from beets.test.helper import FetchImageHelper, TestHelper
from beets.util import clean_module_tempdir, syspath
from beets.util.artresizer import ArtResizer
from beetsplug import fetchart
@@ -68,7 +67,7 @@ class DummyRemoteArtSource(fetchart.RemoteArtSource):
return iter(())
-class UseThePlugin(PytestTestHelper):
+class UseThePlugin(TestHelper):
modules = (fetchart.__name__, ArtResizer.__module__)
@pytest.fixture(autouse=True)
diff --git a/test/plugins/test_autobpm.py b/test/plugins/test_autobpm.py
index 49a3bf195..23c594d09 100644
--- a/test/plugins/test_autobpm.py
+++ b/test/plugins/test_autobpm.py
@@ -1,27 +1,19 @@
import pytest
-from beets.test.helper import ImportHelper, PluginMixin
+from beets.test.helper import ImportHelper, PluginTestHelper
pytestmark = pytest.mark.requires_import("librosa")
-class TestAutoBPMPlugin(PluginMixin, ImportHelper):
+class TestAutoBPMPlugin(ImportHelper, PluginTestHelper):
plugin = "autobpm"
- @pytest.fixture(scope="class", name="lib")
- def fixture_lib(self):
- self.setup_beets()
-
- yield self.lib
-
- self.teardown_beets()
-
- @pytest.fixture(scope="class")
+ @pytest.fixture
def item(self):
return self.add_item_fixture()
- @pytest.fixture(scope="class")
- def importer(self, lib):
+ @pytest.fixture
+ def importer(self):
self.import_media = []
self.prepare_album_for_import(1)
track = self.import_media[0]
@@ -29,27 +21,27 @@ class TestAutoBPMPlugin(PluginMixin, ImportHelper):
track.save()
return self.setup_importer(autotag=False)
- def test_command(self, lib, item):
+ def test_command(self, item):
item.bpm = None
item.store()
- self.run_command("autobpm", lib=lib)
+ self.run_command("autobpm")
item.load()
assert item.bpm == 117
- def test_command_force(self, lib, item):
+ def test_command_force(self, item):
item.bpm = 10
item.store()
- self.run_command("autobpm", lib=lib)
+ self.run_command("autobpm")
item.load()
assert item.bpm == 10
- self.run_command("autobpm", "--force", lib=lib)
+ self.run_command("autobpm", "--force")
item.load()
assert item.bpm == 117
- def test_command_overwrite(self, lib, item):
+ def test_command_overwrite(self, item):
item.bpm = 10
item.store()
@@ -57,23 +49,23 @@ class TestAutoBPMPlugin(PluginMixin, ImportHelper):
self.config[self.plugin]["overwrite"] = True
self.load_plugins()
- self.run_command("autobpm", lib=lib)
+ self.run_command("autobpm")
item.load()
assert item.bpm == 117
- def test_command_quiet(self, lib, item, caplog):
+ def test_command_quiet(self, item, caplog):
item.bpm = 10
item.store()
with caplog.at_level("DEBUG"):
- self.run_command("autobpm", "--quiet", lib=lib)
+ self.run_command("autobpm", "--quiet")
assert not any("already exists" in msg for msg in caplog.messages)
with caplog.at_level("DEBUG"):
- self.run_command("autobpm", lib=lib)
+ self.run_command("autobpm")
assert any("already exists" in msg for msg in caplog.messages)
- def test_import(self, lib, importer):
+ def test_import(self, importer):
importer.run()
- assert lib.items().get().bpm == 117
+ assert self.lib.items().get().bpm == 117
diff --git a/test/plugins/test_chroma.py b/test/plugins/test_chroma.py
index d4269523d..8041e8c91 100644
--- a/test/plugins/test_chroma.py
+++ b/test/plugins/test_chroma.py
@@ -17,7 +17,7 @@ from unittest.mock import MagicMock, patch
import pytest
from beets import metadata_plugins
-from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.autotag import AlbumInfo, TrackInfo
from beets.library import Item
from beets.test.helper import ImportTestCase, IOMixin, PluginMixin
from beetsplug import chroma
diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py
index 1730ab31c..4cd162113 100644
--- a/test/plugins/test_discogs.py
+++ b/test/plugins/test_discogs.py
@@ -37,20 +37,8 @@ def _artist(name: str, **kwargs):
} | kwargs
-class PytestTestHelper(TestHelper):
- """Same as the BeetsTestCase unittest setup but for pytest."""
-
- @pytest.fixture(autouse=True)
- def setup(self):
- self.setup_beets()
- try:
- yield
- finally:
- self.teardown_beets()
-
-
@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock())
-class TestDGAlbumInfo(PytestTestHelper):
+class TestDGAlbumInfo(TestHelper):
def _make_release(self, tracks=None):
"""Returns a Bag that mimics a discogs_client.Release. The list
of elements on the returned Bag is incomplete, including just
@@ -468,7 +456,7 @@ class TestDGAlbumInfo(PytestTestHelper):
@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock())
-class TestDGSearchQuery(PytestTestHelper):
+class TestDGSearchQuery(TestHelper):
def test_default_search_filters_without_extra_tags(self):
"""Discogs search uses only the type filter when no extra_tags are set."""
plugin = DiscogsPlugin()
diff --git a/test/plugins/test_embedart.py b/test/plugins/test_embedart.py
index ea4585534..232d98ff1 100644
--- a/test/plugins/test_embedart.py
+++ b/test/plugins/test_embedart.py
@@ -32,7 +32,7 @@ from beets.test.helper import (
FetchImageHelper,
ImportHelper,
IOMixin,
- PytestPluginTestHelper,
+ PluginTestHelper,
)
from beets.util import bytestring_path, displayable_path, syspath
from beets.util.artresizer import ArtResizer
@@ -77,7 +77,7 @@ def require_artresizer_compare(test):
return wrapper
-class PytestImportHelper(PytestPluginTestHelper, ImportHelper):
+class PytestImportHelper(PluginTestHelper, ImportHelper):
@pytest.fixture(autouse=True)
def setup_import_helper(self, setup):
self.import_media = []
diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py
index d572ffeae..4e9093248 100644
--- a/test/plugins/test_ftintitle.py
+++ b/test/plugins/test_ftintitle.py
@@ -16,265 +16,228 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, TypeAlias
-
import pytest
from beets.library.models import Album
-from beets.test.helper import PluginTestCase
+from beets.test.helper import PluginTestHelper
from beetsplug import ftintitle
-if TYPE_CHECKING:
- from collections.abc import Generator
- from beets.library.models import Item
-
-ConfigValue: TypeAlias = str | bool | list[str]
-
-
-class FtInTitlePluginFunctional(PluginTestCase):
+class TestFtInTitlePluginFunctional(PluginTestHelper):
plugin = "ftintitle"
-
-@pytest.fixture
-def env() -> Generator[FtInTitlePluginFunctional, None, None]:
- case = FtInTitlePluginFunctional(methodName="runTest")
- case.setUp()
- try:
- yield case
- finally:
- case.tearDown()
-
-
-def set_config(
- env: FtInTitlePluginFunctional, cfg: dict[str, ConfigValue] | None
-) -> None:
- cfg = {} if cfg is None else cfg
- defaults = {
- "drop": False,
- "auto": True,
- "keep_in_artist": False,
- "custom_words": [],
- }
- env.config["ftintitle"].set(defaults)
- env.config["ftintitle"].set(cfg)
-
-
-def add_item(
- env: FtInTitlePluginFunctional,
- path: str,
- artist: str,
- title: str,
- albumartist: str | None,
-) -> Item:
- return env.add_item(
- path=path,
- artist=artist,
- artist_sort=artist,
- title=title,
- albumartist=albumartist,
+ @pytest.mark.parametrize(
+ "cfg, cmd_args, given, expected",
+ [
+ pytest.param(
+ {},
+ ("ftintitle",),
+ ("Alice", "Song 1", "Alice"),
+ ("Alice", "Song 1"),
+ id="no-featured-artist",
+ ),
+ pytest.param(
+ {"format": "feat {0}"},
+ ("ftintitle",),
+ ("Alice ft. Bob", "Song 1", None),
+ ("Alice", "Song 1 feat Bob"),
+ id="no-albumartist-custom-format",
+ ),
+ pytest.param(
+ {},
+ ("ftintitle",),
+ ("Alice", "Song 1", None),
+ ("Alice", "Song 1"),
+ id="no-albumartist-no-feature",
+ ),
+ pytest.param(
+ {"format": "featuring {0}"},
+ ("ftintitle",),
+ ("Alice ft Bob", "Song 1", "George"),
+ ("Alice", "Song 1 featuring Bob"),
+ id="guest-artist-custom-format",
+ ),
+ pytest.param(
+ {},
+ ("ftintitle",),
+ ("Alice", "Song 1", "George"),
+ ("Alice", "Song 1"),
+ id="guest-artist-no-feature",
+ ),
+ # ---- drop (-d) variants ----
+ pytest.param(
+ {},
+ ("ftintitle", "-d"),
+ ("Alice ft Bob", "Song 1", "Alice"),
+ ("Alice", "Song 1"),
+ id="drop-self-ft",
+ ),
+ pytest.param(
+ {},
+ ("ftintitle", "-d"),
+ ("Alice", "Song 1", "Alice"),
+ ("Alice", "Song 1"),
+ id="drop-self-no-ft",
+ ),
+ pytest.param(
+ {},
+ ("ftintitle", "-d"),
+ ("Alice ft Bob", "Song 1", "George"),
+ ("Alice", "Song 1"),
+ id="drop-guest-ft",
+ ),
+ pytest.param(
+ {},
+ ("ftintitle", "-d"),
+ ("Alice", "Song 1", "George"),
+ ("Alice", "Song 1"),
+ id="drop-guest-no-ft",
+ ),
+ # ---- custom format variants ----
+ pytest.param(
+ {"format": "feat. {}"},
+ ("ftintitle",),
+ ("Alice ft Bob", "Song 1", "Alice"),
+ ("Alice", "Song 1 feat. Bob"),
+ id="custom-format-feat-dot",
+ ),
+ pytest.param(
+ {"format": "featuring {}"},
+ ("ftintitle",),
+ ("Alice feat. Bob", "Song 1", "Alice"),
+ ("Alice", "Song 1 featuring Bob"),
+ id="custom-format-featuring",
+ ),
+ pytest.param(
+ {"format": "with {}"},
+ ("ftintitle",),
+ ("Alice feat Bob", "Song 1", "Alice"),
+ ("Alice", "Song 1 with Bob"),
+ id="custom-format-with",
+ ),
+ # ---- keep_in_artist variants ----
+ pytest.param(
+ {"format": "feat. {}", "keep_in_artist": True},
+ ("ftintitle",),
+ ("Alice ft Bob", "Song 1", "Alice"),
+ ("Alice ft Bob", "Song 1 feat. Bob"),
+ id="keep-in-artist-add-to-title",
+ ),
+ pytest.param(
+ {"format": "feat. {}", "keep_in_artist": True},
+ ("ftintitle", "-d"),
+ ("Alice ft Bob", "Song 1", "Alice"),
+ ("Alice ft Bob", "Song 1"),
+ id="keep-in-artist-drop-from-title",
+ ),
+ # ---- custom_words variants ----
+ pytest.param(
+ {"format": "featuring {}", "custom_words": ["med"]},
+ ("ftintitle",),
+ ("Alice med Bob", "Song 1", "Alice"),
+ ("Alice", "Song 1 featuring Bob"),
+ id="custom-feat-words",
+ ),
+ pytest.param(
+ {
+ "format": "featuring {}",
+ "keep_in_artist": True,
+ "custom_words": ["med"],
+ },
+ ("ftintitle",),
+ ("Alice med Bob", "Song 1", "Alice"),
+ ("Alice med Bob", "Song 1 featuring Bob"),
+ id="custom-feat-words-keep-in-artists",
+ ),
+ pytest.param(
+ {
+ "format": "featuring {}",
+ "keep_in_artist": True,
+ "custom_words": ["med"],
+ },
+ ("ftintitle", "-d"),
+ ("Alice med Bob", "Song 1", "Alice"),
+ ("Alice med Bob", "Song 1"),
+ id="custom-feat-words-keep-in-artists-drop-from-title",
+ ),
+ # ---- preserve_album_artist variants ----
+ pytest.param(
+ {"format": "feat. {}", "preserve_album_artist": True},
+ ("ftintitle",),
+ ("Alice feat. Bob", "Song 1", "Alice"),
+ ("Alice", "Song 1 feat. Bob"),
+ id="skip-if-artist-and-album-artists-is-the-same-different-match",
+ ),
+ pytest.param(
+ {"format": "feat. {}", "preserve_album_artist": False},
+ ("ftintitle",),
+ ("Alice feat. Bob", "Song 1", "Alice"),
+ ("Alice", "Song 1 feat. Bob"),
+ id="skip-if-artist-and-album-artists-is-the-same-different-match-b",
+ ),
+ pytest.param(
+ {"format": "feat. {}", "preserve_album_artist": True},
+ ("ftintitle",),
+ ("Alice feat. Bob", "Song 1", "Alice feat. Bob"),
+ ("Alice feat. Bob", "Song 1"),
+ id="skip-if-artist-and-album-artists-is-the-same-matching-match",
+ ),
+ pytest.param(
+ {"format": "feat. {}", "preserve_album_artist": False},
+ ("ftintitle",),
+ ("Alice feat. Bob", "Song 1", "Alice feat. Bob"),
+ ("Alice", "Song 1 feat. Bob"),
+ id="skip-if-artist-and-album-artists-is-the-same-matching-match-b",
+ ),
+ # ---- titles with brackets/parentheses ----
+ pytest.param(
+ {"format": "ft. {}", "bracket_keywords": ["mix"]},
+ ("ftintitle",),
+ ("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"),
+ ("Alice", "Song 1 ft. Bob (Club Mix)"),
+ id="ft-inserted-before-matching-bracket-keyword",
+ ),
+ pytest.param(
+ {"format": "ft. {}", "bracket_keywords": ["nomatch"]},
+ ("ftintitle",),
+ ("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"),
+ ("Alice", "Song 1 (Club Remix) ft. Bob"),
+ id="ft-inserted-at-end-no-bracket-keyword-match",
+ ),
+ ],
)
+ def test_ftintitle_functional(
+ self,
+ cfg: dict[str, str | bool | list[str]],
+ cmd_args: tuple[str, ...],
+ given: tuple[str, str, str | None],
+ expected: tuple[str, str],
+ ) -> None:
+ config = {
+ "drop": False,
+ "auto": True,
+ "keep_in_artist": False,
+ "custom_words": [],
+ **cfg,
+ }
+ artist, title, albumartist = given
+ item = self.add_item(
+ path="/",
+ artist=artist,
+ artist_sort=artist,
+ title=title,
+ albumartist=albumartist,
+ )
-@pytest.mark.parametrize(
- "cfg, cmd_args, given, expected",
- [
- pytest.param(
- None,
- ("ftintitle",),
- ("Alice", "Song 1", "Alice"),
- ("Alice", "Song 1"),
- id="no-featured-artist",
- ),
- pytest.param(
- {"format": "feat {0}"},
- ("ftintitle",),
- ("Alice ft. Bob", "Song 1", None),
- ("Alice", "Song 1 feat Bob"),
- id="no-albumartist-custom-format",
- ),
- pytest.param(
- None,
- ("ftintitle",),
- ("Alice", "Song 1", None),
- ("Alice", "Song 1"),
- id="no-albumartist-no-feature",
- ),
- pytest.param(
- {"format": "featuring {0}"},
- ("ftintitle",),
- ("Alice ft Bob", "Song 1", "George"),
- ("Alice", "Song 1 featuring Bob"),
- id="guest-artist-custom-format",
- ),
- pytest.param(
- None,
- ("ftintitle",),
- ("Alice", "Song 1", "George"),
- ("Alice", "Song 1"),
- id="guest-artist-no-feature",
- ),
- # ---- drop (-d) variants ----
- pytest.param(
- None,
- ("ftintitle", "-d"),
- ("Alice ft Bob", "Song 1", "Alice"),
- ("Alice", "Song 1"),
- id="drop-self-ft",
- ),
- pytest.param(
- None,
- ("ftintitle", "-d"),
- ("Alice", "Song 1", "Alice"),
- ("Alice", "Song 1"),
- id="drop-self-no-ft",
- ),
- pytest.param(
- None,
- ("ftintitle", "-d"),
- ("Alice ft Bob", "Song 1", "George"),
- ("Alice", "Song 1"),
- id="drop-guest-ft",
- ),
- pytest.param(
- None,
- ("ftintitle", "-d"),
- ("Alice", "Song 1", "George"),
- ("Alice", "Song 1"),
- id="drop-guest-no-ft",
- ),
- # ---- custom format variants ----
- pytest.param(
- {"format": "feat. {}"},
- ("ftintitle",),
- ("Alice ft Bob", "Song 1", "Alice"),
- ("Alice", "Song 1 feat. Bob"),
- id="custom-format-feat-dot",
- ),
- pytest.param(
- {"format": "featuring {}"},
- ("ftintitle",),
- ("Alice feat. Bob", "Song 1", "Alice"),
- ("Alice", "Song 1 featuring Bob"),
- id="custom-format-featuring",
- ),
- pytest.param(
- {"format": "with {}"},
- ("ftintitle",),
- ("Alice feat Bob", "Song 1", "Alice"),
- ("Alice", "Song 1 with Bob"),
- id="custom-format-with",
- ),
- # ---- keep_in_artist variants ----
- pytest.param(
- {"format": "feat. {}", "keep_in_artist": True},
- ("ftintitle",),
- ("Alice ft Bob", "Song 1", "Alice"),
- ("Alice ft Bob", "Song 1 feat. Bob"),
- id="keep-in-artist-add-to-title",
- ),
- pytest.param(
- {"format": "feat. {}", "keep_in_artist": True},
- ("ftintitle", "-d"),
- ("Alice ft Bob", "Song 1", "Alice"),
- ("Alice ft Bob", "Song 1"),
- id="keep-in-artist-drop-from-title",
- ),
- # ---- custom_words variants ----
- pytest.param(
- {"format": "featuring {}", "custom_words": ["med"]},
- ("ftintitle",),
- ("Alice med Bob", "Song 1", "Alice"),
- ("Alice", "Song 1 featuring Bob"),
- id="custom-feat-words",
- ),
- pytest.param(
- {
- "format": "featuring {}",
- "keep_in_artist": True,
- "custom_words": ["med"],
- },
- ("ftintitle",),
- ("Alice med Bob", "Song 1", "Alice"),
- ("Alice med Bob", "Song 1 featuring Bob"),
- id="custom-feat-words-keep-in-artists",
- ),
- pytest.param(
- {
- "format": "featuring {}",
- "keep_in_artist": True,
- "custom_words": ["med"],
- },
- ("ftintitle", "-d"),
- ("Alice med Bob", "Song 1", "Alice"),
- ("Alice med Bob", "Song 1"),
- id="custom-feat-words-keep-in-artists-drop-from-title",
- ),
- # ---- preserve_album_artist variants ----
- pytest.param(
- {"format": "feat. {}", "preserve_album_artist": True},
- ("ftintitle",),
- ("Alice feat. Bob", "Song 1", "Alice"),
- ("Alice", "Song 1 feat. Bob"),
- id="skip-if-artist-and-album-artists-is-the-same-different-match",
- ),
- pytest.param(
- {"format": "feat. {}", "preserve_album_artist": False},
- ("ftintitle",),
- ("Alice feat. Bob", "Song 1", "Alice"),
- ("Alice", "Song 1 feat. Bob"),
- id="skip-if-artist-and-album-artists-is-the-same-different-match-b",
- ),
- pytest.param(
- {"format": "feat. {}", "preserve_album_artist": True},
- ("ftintitle",),
- ("Alice feat. Bob", "Song 1", "Alice feat. Bob"),
- ("Alice feat. Bob", "Song 1"),
- id="skip-if-artist-and-album-artists-is-the-same-matching-match",
- ),
- pytest.param(
- {"format": "feat. {}", "preserve_album_artist": False},
- ("ftintitle",),
- ("Alice feat. Bob", "Song 1", "Alice feat. Bob"),
- ("Alice", "Song 1 feat. Bob"),
- id="skip-if-artist-and-album-artists-is-the-same-matching-match-b",
- ),
- # ---- titles with brackets/parentheses ----
- pytest.param(
- {"format": "ft. {}", "bracket_keywords": ["mix"]},
- ("ftintitle",),
- ("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"),
- ("Alice", "Song 1 ft. Bob (Club Mix)"),
- id="ft-inserted-before-matching-bracket-keyword",
- ),
- pytest.param(
- {"format": "ft. {}", "bracket_keywords": ["nomatch"]},
- ("ftintitle",),
- ("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"),
- ("Alice", "Song 1 (Club Remix) ft. Bob"),
- id="ft-inserted-at-end-no-bracket-keyword-match",
- ),
- ],
-)
-def test_ftintitle_functional(
- env: FtInTitlePluginFunctional,
- cfg: dict[str, str | bool | list[str]] | None,
- cmd_args: tuple[str, ...],
- given: tuple[str, str, str | None],
- expected: tuple[str, str],
-) -> None:
- set_config(env, cfg)
- ftintitle.FtInTitlePlugin()
+ with self.configure_plugin(config):
+ self.run_command(*cmd_args)
- artist, title, albumartist = given
- item = add_item(env, "/", artist, title, albumartist)
+ item.load()
- env.run_command(*cmd_args)
- item.load()
-
- expected_artist, expected_title = expected
- assert item["artist"] == expected_artist
- assert item["title"] == expected_title
+ expected_artist, expected_title = expected
+ assert item["artist"] == expected_artist
+ assert item["title"] == expected_title
@pytest.mark.parametrize(
diff --git a/test/plugins/test_hook.py b/test/plugins/test_hook.py
index 971fbffc9..08f6cd7fd 100644
--- a/test/plugins/test_hook.py
+++ b/test/plugins/test_hook.py
@@ -22,13 +22,13 @@ from typing import TYPE_CHECKING, ClassVar
import pytest
from beets import plugins
-from beets.test.helper import PytestPluginTestHelper
+from beets.test.helper import PluginTestHelper
if TYPE_CHECKING:
from collections.abc import Callable
-class HookTestCase(PytestPluginTestHelper):
+class HookTestCase(PluginTestHelper):
plugin = "hook"
preload_plugin = False
diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py
index 0a6c99438..285a8ad7f 100644
--- a/test/plugins/test_lyrics.py
+++ b/test/plugins/test_lyrics.py
@@ -165,8 +165,8 @@ class TestHtml:
assert lyrics.Html.normalize_space(initial) == expected
def test_scrape_merge_paragraphs(self):
- text = "one
two
three"
- expected = "one\ntwo\n\nthree"
+ text = 'one
two
three'
+ expected = "one\n\ntwo\n\nthree"
assert lyrics.Html.merge_paragraphs(text) == expected
@@ -451,20 +451,24 @@ class TestLyricsSources(LyricsBackendTest):
"beetsplug.lyrics.LyricsRequestHandler.create_session",
lambda _: requests.Session(),
)
+ expected_lyrics = Lyrics(
+ lyrics_page.lyrics,
+ lyrics_page.backend,
+ url=lyrics_page.url,
+ language=lyrics_page.language,
+ )
- assert lyrics_plugin.find_lyrics(
+ actual_lyrics = lyrics_plugin.find_lyrics(
Item(
artist=lyrics_page.artist,
title=lyrics_page.track_title,
album="",
length=186.0,
)
- ) == Lyrics(
- lyrics_page.lyrics,
- lyrics_page.backend,
- url=lyrics_page.url,
- language=lyrics_page.language,
)
+ assert actual_lyrics
+ assert actual_lyrics.text == expected_lyrics.text
+ assert actual_lyrics == expected_lyrics
class TestGoogleLyrics(LyricsBackendTest):
diff --git a/test/plugins/test_mbcollection.py b/test/plugins/test_mbcollection.py
index 3168fcd30..989429e7d 100644
--- a/test/plugins/test_mbcollection.py
+++ b/test/plugins/test_mbcollection.py
@@ -24,14 +24,6 @@ class TestMbCollectionPlugin(PluginMixin, TestHelper):
self.config["musicbrainz"]["pass"] = "testpass"
self.config["mbcollection"]["collection"] = self.COLLECTION_ID
- @pytest.fixture(autouse=True)
- def helper(self):
- self.setup_beets()
-
- yield self
-
- self.teardown_beets()
-
@pytest.mark.parametrize(
"user_collections,expectation",
[
@@ -66,7 +58,7 @@ class TestMbCollectionPlugin(PluginMixin, TestHelper):
with expectation:
mbcollection.MusicBrainzCollectionPlugin().collection
- def test_mbupdate(self, helper, requests_mock, monkeypatch):
+ def test_mbupdate(self, requests_mock, monkeypatch):
"""Verify mbupdate sync of a MusicBrainz collection with the library.
This test ensures that the command:
@@ -85,7 +77,7 @@ class TestMbCollectionPlugin(PluginMixin, TestHelper):
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
]:
- helper.lib.add(Album(mb_albumid=mb_albumid))
+ self.lib.add(Album(mb_albumid=mb_albumid))
# The relevant collection
requests_mock.get(
@@ -138,13 +130,11 @@ class TestMbCollectionPlugin(PluginMixin, TestHelper):
re.compile(rf".*{collection_releases}/not_in_library")
)
- helper.run_command("mbupdate", "--remove")
+ self.run_command("mbupdate", "--remove")
assert requests_mock.call_count == 6
- def test_mbupdate_logs_unauthorized_errors(
- self, helper, requests_mock, caplog
- ):
+ def test_mbupdate_logs_unauthorized_errors(self, requests_mock, caplog):
response = requests.Response()
response.status_code = 401
requests_mock.get(
@@ -153,7 +143,7 @@ class TestMbCollectionPlugin(PluginMixin, TestHelper):
)
with caplog.at_level("ERROR", logger="beets.mbcollection"):
- helper.run_command("mbupdate")
+ self.run_command("mbupdate")
expected_message = (
"Failed to update MusicBrainz collection: HTTP Error: 401"
diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py
index 74095af55..581fd970a 100644
--- a/test/plugins/test_mbpseudo.py
+++ b/test/plugins/test_mbpseudo.py
@@ -6,8 +6,7 @@ from typing import TYPE_CHECKING
import pytest
-from beets.autotag.distance import Distance
-from beets.autotag.hooks import AlbumInfo, AlbumMatch, TrackInfo
+from beets.autotag import AlbumInfo, AlbumMatch, Distance, TrackInfo
from beets.library import Item
from beets.test.helper import PluginMixin
from beetsplug.mbpseudo import (
diff --git a/test/plugins/test_mbsync.py b/test/plugins/test_mbsync.py
index c9b67a06e..72bccaf7f 100644
--- a/test/plugins/test_mbsync.py
+++ b/test/plugins/test_mbsync.py
@@ -14,12 +14,12 @@
from unittest.mock import Mock, patch
-from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.autotag import AlbumInfo, TrackInfo
from beets.library import Item
-from beets.test.helper import PytestPluginTestHelper
+from beets.test.helper import PluginTestHelper
-class TestMbsyncCli(PytestPluginTestHelper):
+class TestMbsyncCli(PluginTestHelper):
plugin = "mbsync"
@patch(
diff --git a/test/plugins/test_missing.py b/test/plugins/test_missing.py
index a9529e907..de74ac6c4 100644
--- a/test/plugins/test_missing.py
+++ b/test/plugins/test_missing.py
@@ -6,7 +6,7 @@ from unittest.mock import patch
import pytest
-from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.autotag import AlbumInfo, TrackInfo
from beets.library import Album, Item
from beets.test.helper import IOMixin, PluginMixin, TestHelper
diff --git a/test/plugins/test_titlecase.py b/test/plugins/test_titlecase.py
index e079e5cbd..f392cd69f 100644
--- a/test/plugins/test_titlecase.py
+++ b/test/plugins/test_titlecase.py
@@ -16,7 +16,7 @@
from unittest.mock import patch
-from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.autotag import AlbumInfo, TrackInfo
from beets.importer import ImportSession, ImportTask
from beets.library import Item
from beets.test.helper import PluginTestCase
diff --git a/test/test_importer.py b/test/test_importer.py
index 816c3371e..bbc14a69d 100644
--- a/test/test_importer.py
+++ b/test/test_importer.py
@@ -36,8 +36,7 @@ import pytest
from mediafile import MediaFile
from beets import config, importer, logging, util
-from beets.autotag.distance import Distance
-from beets.autotag.hooks import AlbumInfo, AlbumMatch, TrackInfo
+from beets.autotag import AlbumInfo, AlbumMatch, Distance, TrackInfo
from beets.importer.tasks import albums_in_dir
from beets.test import _common
from beets.test.helper import (
diff --git a/test/test_library.py b/test/test_library.py
index ef35c31e9..1d7867501 100644
--- a/test/test_library.py
+++ b/test/test_library.py
@@ -14,6 +14,8 @@
"""Tests for non-query database functions of Item."""
+from __future__ import annotations
+
import os
import os.path
import re
@@ -28,12 +30,11 @@ from mediafile import MediaFile, UnreadableFileError
import beets.dbcore.query
import beets.library
-import beets.logging as blog
from beets import config, plugins, util
from beets.library import Album
from beets.test import _common
from beets.test._common import item
-from beets.test.helper import BeetsTestCase, ItemInDBTestCase, capture_log
+from beets.test.helper import TestHelper
from beets.util import (
as_string,
bytestring_path,
@@ -46,39 +47,53 @@ from beets.util import (
np = util.normpath
-class LoadTest(ItemInDBTestCase):
- def test_load_restores_data_from_db(self):
- original_title = self.i.title
- self.i.title = "something"
- self.i.load()
- assert original_title == self.i.title
+class PytestItemHelper(TestHelper):
+ def get_first_item(self):
+ """Retrieve first item from library."""
+ return next(iter(self.lib.items()))
- def test_load_clears_dirty_flags(self):
- self.i.artist = "something"
- assert "artist" in self.i._dirty
- self.i.load()
- assert "artist" not in self.i._dirty
+ @pytest.fixture
+ def item(self):
+ return _common.item()
+
+ @pytest.fixture
+ def item_in_db(self):
+ return _common.item(self.lib)
-class StoreTest(ItemInDBTestCase):
- def test_store_changes_database_value(self):
+class TestLoad(PytestItemHelper):
+ def test_load_restores_data_from_db(self, item_in_db):
+ original_title = item_in_db.title
+ item_in_db.title = "something"
+ item_in_db.load()
+ assert original_title == item_in_db.title
+
+ def test_load_clears_dirty_flags(self, item_in_db):
+ item_in_db.artist = "something"
+ assert "artist" in item_in_db._dirty
+ item_in_db.load()
+ assert "artist" not in item_in_db._dirty
+
+
+class TestStore(PytestItemHelper):
+ def test_store_changes_database_value(self, item_in_db):
new_year = 1987
- self.i.year = new_year
- self.i.store()
+ item_in_db.year = new_year
+ item_in_db.store()
- assert self.lib.get_item(self.i.id).year == new_year
+ assert self.lib.get_item(item_in_db.id).year == new_year
- def test_store_only_writes_dirty_fields(self):
+ def test_store_only_writes_dirty_fields(self, item_in_db):
new_year = 1987
- self.i._values_fixed["year"] = new_year # change w/o dirtying
- self.i.store()
+ item_in_db._values_fixed["year"] = new_year # change w/o dirtying
+ item_in_db.store()
- assert self.lib.get_item(self.i.id).year != new_year
+ assert self.lib.get_item(item_in_db.id).year != new_year
- def test_store_clears_dirty_flags(self):
- self.i.composers = ["tvp"]
- self.i.store()
- assert "composers" not in self.i._dirty
+ def test_store_clears_dirty_flags(self, item_in_db):
+ item_in_db.composers = ["tvp"]
+ item_in_db.store()
+ assert "composers" not in item_in_db._dirty
def test_store_album_cascades_flex_deletes(self):
album = Album(flex1="Flex-1")
@@ -93,520 +108,502 @@ class StoreTest(ItemInDBTestCase):
assert "flex1" not in album.items()[0]
-class AddTest(BeetsTestCase):
- def setUp(self):
- super().setUp()
- self.i = item()
-
- def test_item_add_inserts_row(self):
- self.lib.add(self.i)
+class TestAdd(PytestItemHelper):
+ def test_item_add_inserts_row(self, item):
+ self.lib.add(item)
new_grouping = (
self.lib._connection()
.execute(
"select grouping from items where composers = ?",
- (self.i._type("composers").to_sql(self.i.composers),),
+ (item._type("composers").to_sql(item.composers),),
)
.fetchone()["grouping"]
)
- assert new_grouping == self.i.grouping
+ assert new_grouping == item.grouping
def test_library_add_path_inserts_row(self):
- i = beets.library.Item.from_path(
+ item = beets.library.Item.from_path(
os.path.join(_common.RSRC, b"full.mp3")
)
- self.lib.add(i)
+ self.lib.add(item)
new_grouping = (
self.lib._connection()
.execute(
"select grouping from items where composers = ?",
- (i._type("composers").to_sql(i.composers),),
+ (item._type("composers").to_sql(item.composers),),
)
.fetchone()["grouping"]
)
- assert new_grouping == self.i.grouping
+ assert new_grouping == item.grouping
- def test_library_add_one_database_change_event(self):
+ def test_library_add_one_database_change_event(
+ self, item, caplog: pytest.LogCaptureFixture
+ ):
"""Test library.add emits only one database_change event."""
- self.item = _common.item()
- self.item.path = beets.util.normpath(
+
+ item.path = beets.util.normpath(
os.path.join(self.temp_dir, b"a", b"b.mp3")
)
- self.item.album = "a"
- self.item.title = "b"
+ item.album = "a"
+ item.title = "b"
- blog.getLogger("beets").set_global_level(blog.DEBUG)
- with capture_log() as logs:
- self.lib.add(self.item)
+ with caplog.at_level("DEBUG", logger="beets"):
+ self.lib.add(item)
- assert logs.count("Sending event: database_change") == 1
+ assert caplog.text.count("Sending event: database_change") == 1
-class RemoveTest(ItemInDBTestCase):
- def test_remove_deletes_from_db(self):
- self.i.remove()
+class TestRemove(PytestItemHelper):
+ def test_remove_deletes_from_db(self, item_in_db):
+ item_in_db.remove()
c = self.lib._connection().execute("select * from items")
assert c.fetchone() is None
-class GetSetTest(BeetsTestCase):
- def setUp(self):
- super().setUp()
- self.i = item()
+class TestGetSet(PytestItemHelper):
+ def test_set_changes_value(self, item):
+ item.bpm = 4915
+ assert item.bpm == 4915
- def test_set_changes_value(self):
- self.i.bpm = 4915
- assert self.i.bpm == 4915
+ def test_set_sets_dirty_flag(self, item):
+ item.comp = not item.comp
+ assert "comp" in item._dirty
- def test_set_sets_dirty_flag(self):
- self.i.comp = not self.i.comp
- assert "comp" in self.i._dirty
+ def test_set_does_not_dirty_if_value_unchanged(self, item):
+ item.title = item.title
+ assert "title" not in item._dirty
- def test_set_does_not_dirty_if_value_unchanged(self):
- self.i.title = self.i.title
- assert "title" not in self.i._dirty
-
- def test_invalid_field_raises_attributeerror(self):
+ def test_invalid_field_raises_attributeerror(self, item):
with pytest.raises(AttributeError):
- self.i.xyzzy
+ item.xyzzy
- def test_album_fallback(self):
+ def test_album_fallback(self, item_in_db):
# integration test of item-album fallback
- i = item(self.lib)
- album = self.lib.add_album([i])
+ album = self.lib.add_album([item_in_db])
album["flex"] = "foo"
album.store()
- assert "flex" in i
- assert "flex" not in i.keys(with_album=False)
- assert i["flex"] == "foo"
- assert i.get("flex") == "foo"
- assert i.get("flex", with_album=False) is None
- assert i.get("flexx") is None
+ assert "flex" in item_in_db
+ assert "flex" not in item_in_db.keys(with_album=False)
+ assert item_in_db["flex"] == "foo"
+ assert item_in_db.get("flex") == "foo"
+ assert item_in_db.get("flex", with_album=False) is None
+ assert item_in_db.get("flexx") is None
-class DestinationTest(BeetsTestCase):
+class TestDestination(PytestItemHelper):
"""Confirm tests handle temporary directory path containing '.'"""
def create_temp_dir(self, **kwargs):
kwargs["prefix"] = "."
return super().create_temp_dir(**kwargs)
- def setUp(self):
- super().setUp()
- self.i = item(self.lib)
-
- def test_directory_works_with_trailing_slash(self):
+ def test_directory_works_with_trailing_slash(self, item_in_db):
self.lib.directory = b"one/"
self.lib.path_formats = [("default", "two")]
- assert self.i.destination() == np("one/two")
+ assert item_in_db.destination() == np("one/two")
- def test_directory_works_without_trailing_slash(self):
+ def test_directory_works_without_trailing_slash(self, item_in_db):
self.lib.directory = b"one"
self.lib.path_formats = [("default", "two")]
- assert self.i.destination() == np("one/two")
+ assert item_in_db.destination() == np("one/two")
- def test_destination_substitutes_metadata_values(self):
+ def test_destination_substitutes_metadata_values(self, item_in_db):
self.lib.directory = b"base"
self.lib.path_formats = [("default", "$album/$artist $title")]
- self.i.title = "three"
- self.i.artist = "two"
- self.i.album = "one"
- assert self.i.destination() == np("base/one/two three")
+ item_in_db.title = "three"
+ item_in_db.artist = "two"
+ item_in_db.album = "one"
+ assert item_in_db.destination() == np("base/one/two three")
- def test_destination_preserves_extension(self):
+ def test_destination_preserves_extension(self, item_in_db):
self.lib.directory = b"base"
self.lib.path_formats = [("default", "$title")]
- self.i.path = "hey.audioformat"
- assert self.i.destination() == np("base/the title.audioformat")
+ item_in_db.path = "hey.audioformat"
+ assert item_in_db.destination() == np("base/the title.audioformat")
- def test_lower_case_extension(self):
+ def test_lower_case_extension(self, item_in_db):
self.lib.directory = b"base"
self.lib.path_formats = [("default", "$title")]
- self.i.path = "hey.MP3"
- assert self.i.destination() == np("base/the title.mp3")
+ item_in_db.path = "hey.MP3"
+ assert item_in_db.destination() == np("base/the title.mp3")
- def test_destination_pads_some_indices(self):
+ def test_destination_pads_some_indices(self, item_in_db):
self.lib.directory = b"base"
self.lib.path_formats = [
("default", "$track $tracktotal $disc $disctotal $bpm")
]
- self.i.track = 1
- self.i.tracktotal = 2
- self.i.disc = 3
- self.i.disctotal = 4
- self.i.bpm = 5
- assert self.i.destination() == np("base/01 02 03 04 5")
+ item_in_db.track = 1
+ item_in_db.tracktotal = 2
+ item_in_db.disc = 3
+ item_in_db.disctotal = 4
+ item_in_db.bpm = 5
+ assert item_in_db.destination() == np("base/01 02 03 04 5")
- def test_destination_pads_date_values(self):
+ def test_destination_pads_date_values(self, item_in_db):
self.lib.directory = b"base"
self.lib.path_formats = [("default", "$year-$month-$day")]
- self.i.year = 1
- self.i.month = 2
- self.i.day = 3
- assert self.i.destination() == np("base/0001-02-03")
+ item_in_db.year = 1
+ item_in_db.month = 2
+ item_in_db.day = 3
+ assert item_in_db.destination() == np("base/0001-02-03")
- def test_destination_escapes_slashes(self):
+ def test_destination_escapes_slashes(self, item_in_db):
self.lib.path_formats = [("default", "$artist/$album/$track $title")]
- self.i.album = "one/two"
- dest = self.i.destination()
+ item_in_db.album = "one/two"
+ dest = item_in_db.destination()
assert b"one" in dest
assert b"two" in dest
assert b"one/two" not in dest
- def test_destination_escapes_leading_dot(self):
+ def test_destination_escapes_leading_dot(self, item_in_db):
self.lib.path_formats = [("default", "$artist/$album/$track $title")]
- self.i.album = ".something"
- dest = self.i.destination()
+ item_in_db.album = ".something"
+ dest = item_in_db.destination()
assert b"something" in dest
assert b"/.something" not in dest
- def test_destination_preserves_legitimate_slashes(self):
+ def test_destination_preserves_legitimate_slashes(self, item_in_db):
self.lib.path_formats = [("default", "$artist/$album/$track $title")]
- self.i.artist = "one"
- self.i.album = "two"
- dest = self.i.destination()
+ item_in_db.artist = "one"
+ item_in_db.album = "two"
+ dest = item_in_db.destination()
assert os.path.join(b"one", b"two") in dest
- def test_destination_long_names_truncated(self):
- self.i.title = "X" * 300
- self.i.artist = "Y" * 300
- for c in self.i.destination().split(util.PATH_SEP):
+ def test_destination_long_names_truncated(self, item_in_db):
+ item_in_db.title = "X" * 300
+ item_in_db.artist = "Y" * 300
+ for c in item_in_db.destination().split(util.PATH_SEP):
assert len(c) <= 255
- def test_destination_long_names_keep_extension(self):
- self.i.title = "X" * 300
- self.i.path = b"something.extn"
- dest = self.i.destination()
+ def test_destination_long_names_keep_extension(self, item_in_db):
+ item_in_db.title = "X" * 300
+ item_in_db.path = b"something.extn"
+ dest = item_in_db.destination()
assert dest[-5:] == b".extn"
- def test_distination_windows_removes_both_separators(self):
- self.i.title = "one \\ two / three.mp3"
+ def test_distination_windows_removes_both_separators(self, item_in_db):
+ item_in_db.title = "one \\ two / three.mp3"
with _common.platform_windows():
- p = self.i.destination()
+ p = item_in_db.destination()
assert b"one \\ two" not in p
assert b"one / two" not in p
assert b"two \\ three" not in p
assert b"two / three" not in p
- def test_path_with_format(self):
+ def test_path_with_format(self, item_in_db):
self.lib.path_formats = [("default", "$artist/$album ($format)")]
- p = self.i.destination()
+ p = item_in_db.destination()
assert b"(FLAC)" in p
def test_heterogeneous_album_gets_single_directory(self):
- i1, i2 = item(), item()
+ i1, i2 = _common.item(self.lib), _common.item(self.lib)
self.lib.add_album([i1, i2])
i1.year, i2.year = 2009, 2010
self.lib.path_formats = [("default", "$album ($year)/$track $title")]
dest1, dest2 = i1.destination(), i2.destination()
assert os.path.dirname(dest1) == os.path.dirname(dest2)
- def test_default_path_for_non_compilations(self):
- self.i.comp = False
- self.lib.add_album([self.i])
+ def test_default_path_for_non_compilations(self, item_in_db):
+ item_in_db.comp = False
+ self.lib.add_album([item_in_db])
self.lib.directory = b"one"
self.lib.path_formats = [("default", "two"), ("comp:true", "three")]
- assert self.i.destination() == np("one/two")
+ assert item_in_db.destination() == np("one/two")
- def test_singleton_path(self):
- i = item(self.lib)
+ def test_singleton_path(self, item_in_db):
self.lib.directory = b"one"
self.lib.path_formats = [
("default", "two"),
("singleton:true", "four"),
("comp:true", "three"),
]
- assert i.destination() == np("one/four")
+ assert item_in_db.destination() == np("one/four")
- def test_comp_before_singleton_path(self):
- i = item(self.lib)
- i.comp = True
+ def test_comp_before_singleton_path(self, item_in_db):
+ item_in_db.comp = True
self.lib.directory = b"one"
self.lib.path_formats = [
("default", "two"),
("comp:true", "three"),
("singleton:true", "four"),
]
- assert i.destination() == np("one/three")
+ assert item_in_db.destination() == np("one/three")
- def test_comp_path(self):
- self.i.comp = True
- self.lib.add_album([self.i])
+ def test_comp_path(self, item_in_db):
+ item_in_db.comp = True
+ self.lib.add_album([item_in_db])
self.lib.directory = b"one"
self.lib.path_formats = [("default", "two"), ("comp:true", "three")]
- assert self.i.destination() == np("one/three")
+ assert item_in_db.destination() == np("one/three")
- def test_multi_value_string_query_path(self):
- self.i.genres = ["Classical"]
+ def test_multi_value_string_query_path(self, item_in_db):
+ item_in_db.genres = ["Classical"]
self.lib.directory = b"one"
self.lib.path_formats = [
("default", "two"),
("genres:=~Classical", "three"),
]
- assert self.i.destination() == np("one/three")
+ assert item_in_db.destination() == np("one/three")
- def test_multi_value_match_query_path(self):
- self.i.genres = ["Classical"]
+ def test_multi_value_match_query_path(self, item_in_db):
+ item_in_db.genres = ["Classical"]
self.lib.directory = b"one"
self.lib.path_formats = [
("default", "two"),
("genres:=Classical", "three"),
]
- assert self.i.destination() == np("one/three")
+ assert item_in_db.destination() == np("one/three")
- def test_multi_value_string_query_path_no_substring_match(self):
- self.i.genres = ["Neoclassical"]
+ def test_multi_value_string_query_path_no_substring_match(self, item_in_db):
+ item_in_db.genres = ["Neoclassical"]
self.lib.directory = b"one"
self.lib.path_formats = [
("default", "two"),
("genres:=~Classical", "three"),
]
- assert self.i.destination() == np("one/two")
+ assert item_in_db.destination() == np("one/two")
- def test_albumtype_query_path(self):
- self.i.comp = True
- self.lib.add_album([self.i])
- self.i.albumtype = "sometype"
+ def test_albumtype_query_path(self, item_in_db):
+ item_in_db.comp = True
+ self.lib.add_album([item_in_db])
+ item_in_db.albumtype = "sometype"
self.lib.directory = b"one"
self.lib.path_formats = [
("default", "two"),
("albumtype:sometype", "four"),
("comp:true", "three"),
]
- assert self.i.destination() == np("one/four")
+ assert item_in_db.destination() == np("one/four")
- def test_albumtype_path_fallback_to_comp(self):
- self.i.comp = True
- self.lib.add_album([self.i])
- self.i.albumtype = "sometype"
+ def test_albumtype_path_fallback_to_comp(self, item_in_db):
+ item_in_db.comp = True
+ self.lib.add_album([item_in_db])
+ item_in_db.albumtype = "sometype"
self.lib.directory = b"one"
self.lib.path_formats = [
("default", "two"),
("albumtype:anothertype", "four"),
("comp:true", "three"),
]
- assert self.i.destination() == np("one/three")
+ assert item_in_db.destination() == np("one/three")
- def test_get_formatted_does_not_replace_separators(self):
+ def test_get_formatted_does_not_replace_separators(self, item_in_db):
with _common.platform_posix():
name = os.path.join("a", "b")
- self.i.title = name
- newname = self.i.formatted().get("title")
+ item_in_db.title = name
+ newname = item_in_db.formatted().get("title")
assert name == newname
- def test_get_formatted_pads_with_zero(self):
+ def test_get_formatted_pads_with_zero(self, item_in_db):
with _common.platform_posix():
- self.i.track = 1
- name = self.i.formatted().get("track")
+ item_in_db.track = 1
+ name = item_in_db.formatted().get("track")
assert name.startswith("0")
- def test_get_formatted_uses_kbps_bitrate(self):
+ def test_get_formatted_uses_kbps_bitrate(self, item_in_db):
with _common.platform_posix():
- self.i.bitrate = 12345
- val = self.i.formatted().get("bitrate")
+ item_in_db.bitrate = 12345
+ val = item_in_db.formatted().get("bitrate")
assert val == "12kbps"
- def test_get_formatted_uses_khz_samplerate(self):
+ def test_get_formatted_uses_khz_samplerate(self, item_in_db):
with _common.platform_posix():
- self.i.samplerate = 12345
- val = self.i.formatted().get("samplerate")
+ item_in_db.samplerate = 12345
+ val = item_in_db.formatted().get("samplerate")
assert val == "12kHz"
- def test_get_formatted_datetime(self):
+ def test_get_formatted_datetime(self, item_in_db):
with _common.platform_posix():
- self.i.added = 1368302461.210265
- val = self.i.formatted().get("added")
+ item_in_db.added = 1368302461.210265
+ val = item_in_db.formatted().get("added")
assert val.startswith("2013")
- def test_get_formatted_none(self):
+ def test_get_formatted_none(self, item_in_db):
with _common.platform_posix():
- self.i.some_other_field = None
- val = self.i.formatted().get("some_other_field")
+ item_in_db.some_other_field = None
+ val = item_in_db.formatted().get("some_other_field")
assert val == ""
- def test_artist_falls_back_to_albumartist(self):
- self.i.artist = ""
- self.i.albumartist = "something"
+ def test_artist_falls_back_to_albumartist(self, item_in_db):
+ item_in_db.artist = ""
+ item_in_db.albumartist = "something"
self.lib.path_formats = [("default", "$artist")]
- p = self.i.destination()
+ p = item_in_db.destination()
assert p.rsplit(util.PATH_SEP, 1)[1] == b"something"
- def test_albumartist_falls_back_to_artist(self):
- self.i.artist = "trackartist"
- self.i.albumartist = ""
+ def test_albumartist_falls_back_to_artist(self, item_in_db):
+ item_in_db.artist = "trackartist"
+ item_in_db.albumartist = ""
self.lib.path_formats = [("default", "$albumartist")]
- p = self.i.destination()
+ p = item_in_db.destination()
assert p.rsplit(util.PATH_SEP, 1)[1] == b"trackartist"
- def test_artist_overrides_albumartist(self):
- self.i.artist = "theartist"
- self.i.albumartist = "something"
+ def test_artist_overrides_albumartist(self, item_in_db):
+ item_in_db.artist = "theartist"
+ item_in_db.albumartist = "something"
self.lib.path_formats = [("default", "$artist")]
- p = self.i.destination()
+ p = item_in_db.destination()
assert p.rsplit(util.PATH_SEP, 1)[1] == b"theartist"
- def test_albumartist_overrides_artist(self):
- self.i.artist = "theartist"
- self.i.albumartist = "something"
+ def test_albumartist_overrides_artist(self, item_in_db):
+ item_in_db.artist = "theartist"
+ item_in_db.albumartist = "something"
self.lib.path_formats = [("default", "$albumartist")]
- p = self.i.destination()
+ p = item_in_db.destination()
assert p.rsplit(util.PATH_SEP, 1)[1] == b"something"
- def test_unicode_normalized_nfd_on_mac(self):
+ def test_unicode_normalized_nfd_on_mac(self, item_in_db):
instr = unicodedata.normalize("NFC", "caf\xe9")
self.lib.path_formats = [("default", instr)]
with patch("sys.platform", "darwin"):
- dest = self.i.destination(relative_to_libdir=True)
+ dest = item_in_db.destination(relative_to_libdir=True)
assert as_string(dest) == unicodedata.normalize("NFD", instr)
- def test_unicode_normalized_nfc_on_linux(self):
+ def test_unicode_normalized_nfc_on_linux(self, item_in_db):
instr = unicodedata.normalize("NFD", "caf\xe9")
self.lib.path_formats = [("default", instr)]
with patch("sys.platform", "linux"):
- dest = self.i.destination(relative_to_libdir=True)
+ dest = item_in_db.destination(relative_to_libdir=True)
assert as_string(dest) == unicodedata.normalize("NFC", instr)
- def test_unicode_extension_in_fragment(self):
+ def test_unicode_extension_in_fragment(self, item_in_db):
self.lib.path_formats = [("default", "foo")]
- self.i.path = util.bytestring_path("bar.caf\xe9")
+ item_in_db.path = util.bytestring_path("bar.caf\xe9")
with patch("sys.platform", "linux"):
- dest = self.i.destination(relative_to_libdir=True)
+ dest = item_in_db.destination(relative_to_libdir=True)
assert as_string(dest) == "foo.caf\xe9"
- def test_asciify_and_replace(self):
+ def test_asciify_and_replace(self, item_in_db):
config["asciify_paths"] = True
self.lib.replacements = [(re.compile('"'), "q")]
self.lib.directory = b"lib"
self.lib.path_formats = [("default", "$title")]
- self.i.title = "\u201c\u00f6\u2014\u00cf\u201d"
- assert self.i.destination() == np("lib/qo--Iq")
+ item_in_db.title = "\u201c\u00f6\u2014\u00cf\u201d"
+ assert item_in_db.destination() == np("lib/qo--Iq")
- def test_asciify_character_expanding_to_slash(self):
+ def test_asciify_character_expanding_to_slash(self, item_in_db):
config["asciify_paths"] = True
self.lib.directory = b"lib"
self.lib.path_formats = [("default", "$title")]
- self.i.title = "ab\xa2\xbdd"
- assert self.i.destination() == np("lib/abC_ 1_2d")
+ item_in_db.title = "ab\xa2\xbdd"
+ assert item_in_db.destination() == np("lib/abC_ 1_2d")
- def test_destination_with_replacements(self):
+ def test_destination_with_replacements(self, item_in_db):
self.lib.directory = b"base"
self.lib.replacements = [(re.compile(r"a"), "e")]
self.lib.path_formats = [("default", "$album/$title")]
- self.i.title = "foo"
- self.i.album = "bar"
- assert self.i.destination() == np("base/ber/foo")
+ item_in_db.title = "foo"
+ item_in_db.album = "bar"
+ assert item_in_db.destination() == np("base/ber/foo")
@unittest.skip("unimplemented: #359")
- def test_destination_with_empty_component(self):
+ def test_destination_with_empty_component(self, item_in_db):
self.lib.directory = b"base"
self.lib.replacements = [(re.compile(r"^$"), "_")]
self.lib.path_formats = [("default", "$album/$artist/$title")]
- self.i.title = "three"
- self.i.artist = ""
- self.i.albumartist = ""
- self.i.album = "one"
- assert self.i.destination() == np("base/one/_/three")
+ item_in_db.title = "three"
+ item_in_db.artist = ""
+ item_in_db.albumartist = ""
+ item_in_db.album = "one"
+ assert item_in_db.destination() == np("base/one/_/three")
@unittest.skip("unimplemented: #359")
- def test_destination_with_empty_final_component(self):
+ def test_destination_with_empty_final_component(self, item_in_db):
self.lib.directory = b"base"
self.lib.replacements = [(re.compile(r"^$"), "_")]
self.lib.path_formats = [("default", "$album/$title")]
- self.i.title = ""
- self.i.album = "one"
- self.i.path = "foo.mp3"
- assert self.i.destination() == np("base/one/_.mp3")
+ item_in_db.title = ""
+ item_in_db.album = "one"
+ item_in_db.path = "foo.mp3"
+ assert item_in_db.destination() == np("base/one/_.mp3")
- def test_album_field_query(self):
+ def test_album_field_query(self, item_in_db):
self.lib.directory = b"one"
self.lib.path_formats = [("default", "two"), ("flex:foo", "three")]
- album = self.lib.add_album([self.i])
- assert self.i.destination() == np("one/two")
+ album = self.lib.add_album([item_in_db])
+ assert item_in_db.destination() == np("one/two")
album["flex"] = "foo"
album.store()
- assert self.i.destination() == np("one/three")
+ assert item_in_db.destination() == np("one/three")
- def test_album_field_in_template(self):
+ def test_album_field_in_template(self, item_in_db):
self.lib.directory = b"one"
self.lib.path_formats = [("default", "$flex/two")]
- album = self.lib.add_album([self.i])
+ album = self.lib.add_album([item_in_db])
album["flex"] = "foo"
album.store()
- assert self.i.destination() == np("one/foo/two")
+ assert item_in_db.destination() == np("one/foo/two")
-class ItemFormattedMappingTest(ItemInDBTestCase):
- def test_formatted_item_value(self):
- formatted = self.i.formatted()
+class TestItemFormattedMapping(PytestItemHelper):
+ def test_formatted_item_value(self, item_in_db):
+ formatted = item_in_db.formatted()
assert formatted["artist"] == "the artist"
- def test_get_unset_field(self):
- formatted = self.i.formatted()
+ def test_get_unset_field(self, item_in_db):
+ formatted = item_in_db.formatted()
with pytest.raises(KeyError):
formatted["other_field"]
- def test_get_method_with_default(self):
- formatted = self.i.formatted()
+ def test_get_method_with_default(self, item_in_db):
+ formatted = item_in_db.formatted()
assert formatted.get("other_field") == ""
- def test_get_method_with_specified_default(self):
- formatted = self.i.formatted()
+ def test_get_method_with_specified_default(self, item_in_db):
+ formatted = item_in_db.formatted()
assert formatted.get("other_field", "default") == "default"
- def test_item_precedence(self):
- album = self.lib.add_album([self.i])
+ def test_item_precedence(self, item_in_db):
+ album = self.lib.add_album([item_in_db])
album["artist"] = "foo"
album.store()
- assert "foo" != self.i.formatted().get("artist")
+ assert "foo" != item_in_db.formatted().get("artist")
- def test_album_flex_field(self):
- album = self.lib.add_album([self.i])
+ def test_album_flex_field(self, item_in_db):
+ album = self.lib.add_album([item_in_db])
album["flex"] = "foo"
album.store()
- assert "foo" == self.i.formatted().get("flex")
+ assert "foo" == item_in_db.formatted().get("flex")
- def test_album_field_overrides_item_field_for_path(self):
+ def test_album_field_overrides_item_field_for_path(self, item_in_db):
# Make the album inconsistent with the item.
- album = self.lib.add_album([self.i])
+ album = self.lib.add_album([item_in_db])
album.album = "foo"
album.store()
- self.i.album = "bar"
- self.i.store()
+ item_in_db.album = "bar"
+ item_in_db.store()
# Ensure the album takes precedence.
- formatted = self.i.formatted(for_path=True)
+ formatted = item_in_db.formatted(for_path=True)
assert formatted["album"] == "foo"
- def test_artist_falls_back_to_albumartist(self):
- self.i.artist = ""
- formatted = self.i.formatted()
+ def test_artist_falls_back_to_albumartist(self, item_in_db):
+ item_in_db.artist = ""
+ formatted = item_in_db.formatted()
assert formatted["artist"] == "the album artist"
- def test_albumartist_falls_back_to_artist(self):
- self.i.albumartist = ""
- formatted = self.i.formatted()
+ def test_albumartist_falls_back_to_artist(self, item_in_db):
+ item_in_db.albumartist = ""
+ formatted = item_in_db.formatted()
assert formatted["albumartist"] == "the artist"
- def test_both_artist_and_albumartist_empty(self):
- self.i.artist = ""
- self.i.albumartist = ""
- formatted = self.i.formatted()
+ def test_both_artist_and_albumartist_empty(self, item_in_db):
+ item_in_db.artist = ""
+ item_in_db.albumartist = ""
+ formatted = item_in_db.formatted()
assert formatted["albumartist"] == ""
class PathFormattingMixin:
"""Utilities for testing path formatting."""
- i: beets.library.Item
lib: beets.library.Library
def _setf(self, fmt):
self.lib.path_formats.insert(0, ("default", fmt))
- def _assert_dest(self, dest, i=None):
- if i is None:
- i = self.i
-
+ def _assert_dest(self, dest, item):
# Handle paths on Windows.
if os.path.sep != "/":
dest = dest.replace(b"/", os.path.sep.encode())
@@ -614,287 +611,309 @@ class PathFormattingMixin:
# Paths are normalized based on the CWD.
dest = normpath(dest)
- actual = i.destination()
+ actual = item.destination()
assert actual == dest
-class DestinationFunctionTest(BeetsTestCase, PathFormattingMixin):
- def setUp(self):
- super().setUp()
+class TestDestinationFunction(TestHelper, PathFormattingMixin):
+ @pytest.fixture(autouse=True)
+ def item(self, setup):
self.lib.directory = b"/base"
self.lib.path_formats = [("default", "path")]
- self.i = item(self.lib)
+ return item(self.lib)
- def test_upper_case_literal(self):
+ def test_upper_case_literal(self, item):
self._setf("%upper{foo}")
- self._assert_dest(b"/base/FOO")
+ self._assert_dest(b"/base/FOO", item)
- def test_upper_case_variable(self):
+ def test_upper_case_variable(self, item):
self._setf("%upper{$title}")
- self._assert_dest(b"/base/THE TITLE")
+ self._assert_dest(b"/base/THE TITLE", item)
- def test_capitalize_variable(self):
+ def test_capitalize_variable(self, item):
self._setf("%capitalize{$title}")
- self._assert_dest(b"/base/The title")
+ self._assert_dest(b"/base/The title", item)
- def test_title_case_variable(self):
+ def test_title_case_variable(self, item):
self._setf("%title{$title}")
- self._assert_dest(b"/base/The Title")
+ self._assert_dest(b"/base/The Title", item)
- def test_title_case_variable_aphostrophe(self):
+ def test_title_case_variable_aphostrophe(self, item):
self._setf("%title{I can't}")
- self._assert_dest(b"/base/I Can't")
+ self._assert_dest(b"/base/I Can't", item)
- def test_asciify_variable(self):
+ def test_asciify_variable(self, item):
self._setf("%asciify{ab\xa2\xbdd}")
- self._assert_dest(b"/base/abC_ 1_2d")
+ self._assert_dest(b"/base/abC_ 1_2d", item)
- def test_left_variable(self):
+ def test_left_variable(self, item):
self._setf("%left{$title, 3}")
- self._assert_dest(b"/base/the")
+ self._assert_dest(b"/base/the", item)
- def test_right_variable(self):
+ def test_right_variable(self, item):
self._setf("%right{$title,3}")
- self._assert_dest(b"/base/tle")
+ self._assert_dest(b"/base/tle", item)
- def test_if_false(self):
+ def test_if_false(self, item):
self._setf("x%if{,foo}")
- self._assert_dest(b"/base/x")
+ self._assert_dest(b"/base/x", item)
- def test_if_false_value(self):
+ def test_if_false_value(self, item):
self._setf("x%if{false,foo}")
- self._assert_dest(b"/base/x")
+ self._assert_dest(b"/base/x", item)
- def test_if_true(self):
+ def test_if_true(self, item):
self._setf("%if{bar,foo}")
- self._assert_dest(b"/base/foo")
+ self._assert_dest(b"/base/foo", item)
- def test_if_else_false(self):
+ def test_if_else_false(self, item):
self._setf("%if{,foo,baz}")
- self._assert_dest(b"/base/baz")
+ self._assert_dest(b"/base/baz", item)
- def test_if_else_false_value(self):
+ def test_if_else_false_value(self, item):
self._setf("%if{false,foo,baz}")
- self._assert_dest(b"/base/baz")
+ self._assert_dest(b"/base/baz", item)
- def test_if_int_value(self):
+ def test_if_int_value(self, item):
self._setf("%if{0,foo,baz}")
- self._assert_dest(b"/base/baz")
+ self._assert_dest(b"/base/baz", item)
- def test_nonexistent_function(self):
+ def test_nonexistent_function(self, item):
self._setf("%foo{bar}")
- self._assert_dest(b"/base/%foo{bar}")
+ self._assert_dest(b"/base/%foo{bar}", item)
- def test_if_def_field_return_self(self):
- self.i.bar = 3
+ def test_if_def_field_return_self(self, item):
+ item.bar = 3
self._setf("%ifdef{bar}")
- self._assert_dest(b"/base/3")
+ self._assert_dest(b"/base/3", item)
- def test_if_def_field_not_defined(self):
+ def test_if_def_field_not_defined(self, item):
self._setf(" %ifdef{bar}/$artist")
- self._assert_dest(b"/base/the artist")
+ self._assert_dest(b"/base/the artist", item)
- def test_if_def_field_not_defined_2(self):
+ def test_if_def_field_not_defined_2(self, item):
self._setf("$artist/%ifdef{bar}")
- self._assert_dest(b"/base/the artist")
+ self._assert_dest(b"/base/the artist", item)
- def test_if_def_true(self):
+ def test_if_def_true(self, item):
self._setf("%ifdef{artist,cool}")
- self._assert_dest(b"/base/cool")
+ self._assert_dest(b"/base/cool", item)
- def test_if_def_true_complete(self):
- self.i.series = "Now"
+ def test_if_def_true_complete(self, item):
+ item.series = "Now"
self._setf("%ifdef{series,$series Series,Albums}/$album")
- self._assert_dest(b"/base/Now Series/the album")
+ self._assert_dest(b"/base/Now Series/the album", item)
- def test_if_def_false_complete(self):
+ def test_if_def_false_complete(self, item):
self._setf("%ifdef{plays,$plays,not_played}")
- self._assert_dest(b"/base/not_played")
+ self._assert_dest(b"/base/not_played", item)
- def test_first(self):
- self.i.albumtypes = ["album", "compilation"]
+ def test_first(self, item):
+ item.albumtypes = ["album", "compilation"]
self._setf("%first{$albumtypes}")
- self._assert_dest(b"/base/album")
+ self._assert_dest(b"/base/album", item)
- def test_first_skip(self):
- self.i.albumtype = "album; ep; compilation"
+ def test_first_skip(self, item):
+ item.albumtype = "album; ep; compilation"
self._setf("%first{$albumtype,1,2}")
- self._assert_dest(b"/base/compilation")
+ self._assert_dest(b"/base/compilation", item)
- def test_first_different_sep(self):
+ def test_first_different_sep(self, item):
self._setf("%first{Alice / Bob / Eve,2,0, / , & }")
- self._assert_dest(b"/base/Alice & Bob")
+ self._assert_dest(b"/base/Alice & Bob", item)
-class DisambiguationTest(BeetsTestCase, PathFormattingMixin):
- def setUp(self):
- super().setUp()
+class TestDisambiguation(TestHelper, PathFormattingMixin):
+ @pytest.fixture(autouse=True)
+ def items(self, setup):
self.lib.directory = b"/base"
self.lib.path_formats = [("default", "path")]
- self.i1 = item()
- self.i1.year = 2001
- self.lib.add_album([self.i1])
- self.i2 = item()
- self.i2.year = 2002
- self.lib.add_album([self.i2])
+ i1 = item()
+ i1.year = 2001
+ self.lib.add_album([i1])
+ i2 = item()
+ i2.year = 2002
+ self.lib.add_album([i2])
self.lib._connection().commit()
self._setf("foo%aunique{albumartist album,year}/$title")
+ return i1, i2
- def test_unique_expands_to_disambiguating_year(self):
- self._assert_dest(b"/base/foo [2001]/the title", self.i1)
+ def test_unique_expands_to_disambiguating_year(self, items):
+ i1, _i2 = items
+ self._assert_dest(b"/base/foo [2001]/the title", i1)
- def test_unique_with_default_arguments_uses_albumtype(self):
- album2 = self.lib.get_album(self.i1)
+ def test_unique_with_default_arguments_uses_albumtype(self, items):
+ i1, _i2 = items
+ album2 = self.lib.get_album(i1)
album2.albumtype = "bar"
album2.store()
self._setf("foo%aunique{}/$title")
- self._assert_dest(b"/base/foo [bar]/the title", self.i1)
+ self._assert_dest(b"/base/foo [bar]/the title", i1)
- def test_unique_expands_to_nothing_for_distinct_albums(self):
- album2 = self.lib.get_album(self.i2)
+ def test_unique_expands_to_nothing_for_distinct_albums(self, items):
+ i1, i2 = items
+ album2 = self.lib.get_album(i2)
album2.album = "different album"
album2.store()
- self._assert_dest(b"/base/foo/the title", self.i1)
+ self._assert_dest(b"/base/foo/the title", i1)
- def test_use_fallback_numbers_when_identical(self):
- album2 = self.lib.get_album(self.i2)
+ def test_use_fallback_numbers_when_identical(self, items):
+ i1, i2 = items
+ album2 = self.lib.get_album(i2)
album2.year = 2001
album2.store()
- self._assert_dest(b"/base/foo [1]/the title", self.i1)
- self._assert_dest(b"/base/foo [2]/the title", self.i2)
+ self._assert_dest(b"/base/foo [1]/the title", i1)
+ self._assert_dest(b"/base/foo [2]/the title", i2)
- def test_unique_falls_back_to_second_distinguishing_field(self):
+ def test_unique_falls_back_to_second_distinguishing_field(self, items):
+ i1, _i2 = items
self._setf("foo%aunique{albumartist album,month year}/$title")
- self._assert_dest(b"/base/foo [2001]/the title", self.i1)
+ self._assert_dest(b"/base/foo [2001]/the title", i1)
- def test_unique_sanitized(self):
- album2 = self.lib.get_album(self.i2)
+ def test_unique_sanitized(self, items):
+ i1, i2 = items
+ album2 = self.lib.get_album(i2)
album2.year = 2001
- album1 = self.lib.get_album(self.i1)
+ album1 = self.lib.get_album(i1)
album1.albumtype = "foo/bar"
album2.store()
album1.store()
self._setf("foo%aunique{albumartist album,albumtype}/$title")
- self._assert_dest(b"/base/foo [foo_bar]/the title", self.i1)
+ self._assert_dest(b"/base/foo [foo_bar]/the title", i1)
- def test_drop_empty_disambig_string(self):
- album1 = self.lib.get_album(self.i1)
+ def test_drop_empty_disambig_string(self, items):
+ i1, i2 = items
+ album1 = self.lib.get_album(i1)
album1.albumdisambig = None
- album2 = self.lib.get_album(self.i2)
+ album2 = self.lib.get_album(i2)
album2.albumdisambig = "foo"
album1.store()
album2.store()
self._setf("foo%aunique{albumartist album,albumdisambig}/$title")
- self._assert_dest(b"/base/foo/the title", self.i1)
+ self._assert_dest(b"/base/foo/the title", i1)
- def test_change_brackets(self):
+ def test_change_brackets(self, items):
+ i1, _i2 = items
self._setf("foo%aunique{albumartist album,year,()}/$title")
- self._assert_dest(b"/base/foo (2001)/the title", self.i1)
+ self._assert_dest(b"/base/foo (2001)/the title", i1)
- def test_remove_brackets(self):
+ def test_remove_brackets(self, items):
+ i1, _i2 = items
self._setf("foo%aunique{albumartist album,year,}/$title")
- self._assert_dest(b"/base/foo 2001/the title", self.i1)
+ self._assert_dest(b"/base/foo 2001/the title", i1)
- def test_key_flexible_attribute(self):
- album1 = self.lib.get_album(self.i1)
+ def test_key_flexible_attribute(self, items):
+ i1, i2 = items
+ album1 = self.lib.get_album(i1)
album1.flex = "flex1"
- album2 = self.lib.get_album(self.i2)
+ album2 = self.lib.get_album(i2)
album2.flex = "flex2"
album1.store()
album2.store()
self._setf("foo%aunique{albumartist album flex,year}/$title")
- self._assert_dest(b"/base/foo/the title", self.i1)
+ self._assert_dest(b"/base/foo/the title", i1)
-class SingletonDisambiguationTest(BeetsTestCase, PathFormattingMixin):
- def setUp(self):
- super().setUp()
+class TestSingletonDisambiguation(TestHelper, PathFormattingMixin):
+ @pytest.fixture(autouse=True)
+ def items(self, setup):
self.lib.directory = b"/base"
self.lib.path_formats = [("default", "path")]
- self.i1 = item()
- self.i1.year = 2001
- self.lib.add(self.i1)
- self.i2 = item()
- self.i2.year = 2002
- self.lib.add(self.i2)
+ i1 = item()
+ i1.year = 2001
+ self.lib.add(i1)
+ i2 = item()
+ i2.year = 2002
+ self.lib.add(i2)
self.lib._connection().commit()
self._setf("foo/$title%sunique{artist title,year}")
+ return i1, i2
- def test_sunique_expands_to_disambiguating_year(self):
- self._assert_dest(b"/base/foo/the title [2001]", self.i1)
+ def test_sunique_expands_to_disambiguating_year(self, items):
+ i1, _i2 = items
+ self._assert_dest(b"/base/foo/the title [2001]", i1)
- def test_sunique_with_default_arguments_uses_trackdisambig(self):
- self.i1.trackdisambig = "live version"
- self.i1.year = self.i2.year
- self.i1.store()
+ def test_sunique_with_default_arguments_uses_trackdisambig(self, items):
+ i1, i2 = items
+ i1.trackdisambig = "live version"
+ i1.year = i2.year
+ i1.store()
self._setf("foo/$title%sunique{}")
- self._assert_dest(b"/base/foo/the title [live version]", self.i1)
+ self._assert_dest(b"/base/foo/the title [live version]", i1)
- def test_sunique_expands_to_nothing_for_distinct_singletons(self):
- self.i2.title = "different track"
- self.i2.store()
+ def test_sunique_expands_to_nothing_for_distinct_singletons(self, items):
+ i1, i2 = items
+ i2.title = "different track"
+ i2.store()
- self._assert_dest(b"/base/foo/the title", self.i1)
+ self._assert_dest(b"/base/foo/the title", i1)
- def test_sunique_does_not_match_album(self):
- self.lib.add_album([self.i2])
- self._assert_dest(b"/base/foo/the title", self.i1)
+ def test_sunique_does_not_match_album(self, items):
+ i1, i2 = items
+ self.lib.add_album([i2])
+ self._assert_dest(b"/base/foo/the title", i1)
- def test_sunique_use_fallback_numbers_when_identical(self):
- self.i2.year = self.i1.year
- self.i2.store()
+ def test_sunique_use_fallback_numbers_when_identical(self, items):
+ i1, i2 = items
+ i2.year = i1.year
+ i2.store()
- self._assert_dest(b"/base/foo/the title [1]", self.i1)
- self._assert_dest(b"/base/foo/the title [2]", self.i2)
+ self._assert_dest(b"/base/foo/the title [1]", i1)
+ self._assert_dest(b"/base/foo/the title [2]", i2)
- def test_sunique_falls_back_to_second_distinguishing_field(self):
+ def test_sunique_falls_back_to_second_distinguishing_field(self, items):
+ i1, _i2 = items
self._setf("foo/$title%sunique{albumartist album,month year}")
- self._assert_dest(b"/base/foo/the title [2001]", self.i1)
+ self._assert_dest(b"/base/foo/the title [2001]", i1)
- def test_sunique_sanitized(self):
- self.i2.year = self.i1.year
- self.i1.trackdisambig = "foo/bar"
- self.i2.store()
- self.i1.store()
+ def test_sunique_sanitized(self, items):
+ i1, i2 = items
+ i2.year = i1.year
+ i1.trackdisambig = "foo/bar"
+ i2.store()
+ i1.store()
self._setf("foo/$title%sunique{artist title,trackdisambig}")
- self._assert_dest(b"/base/foo/the title [foo_bar]", self.i1)
+ self._assert_dest(b"/base/foo/the title [foo_bar]", i1)
- def test_drop_empty_disambig_string(self):
- self.i1.trackdisambig = None
- self.i2.trackdisambig = "foo"
- self.i1.store()
- self.i2.store()
+ def test_drop_empty_disambig_string(self, items):
+ i1, i2 = items
+ i1.trackdisambig = None
+ i2.trackdisambig = "foo"
+ i1.store()
+ i2.store()
self._setf("foo/$title%sunique{albumartist album,trackdisambig}")
- self._assert_dest(b"/base/foo/the title", self.i1)
+ self._assert_dest(b"/base/foo/the title", i1)
- def test_change_brackets(self):
+ def test_change_brackets(self, items):
+ i1, _i2 = items
self._setf("foo/$title%sunique{artist title,year,()}")
- self._assert_dest(b"/base/foo/the title (2001)", self.i1)
+ self._assert_dest(b"/base/foo/the title (2001)", i1)
- def test_remove_brackets(self):
+ def test_remove_brackets(self, items):
+ i1, _i2 = items
self._setf("foo/$title%sunique{artist title,year,}")
- self._assert_dest(b"/base/foo/the title 2001", self.i1)
+ self._assert_dest(b"/base/foo/the title 2001", i1)
- def test_key_flexible_attribute(self):
- self.i1.flex = "flex1"
- self.i2.flex = "flex2"
- self.i1.store()
- self.i2.store()
+ def test_key_flexible_attribute(self, items):
+ i1, i2 = items
+ i1.flex = "flex1"
+ i2.flex = "flex2"
+ i1.store()
+ i2.store()
self._setf("foo/$title%sunique{artist title flex,year}")
- self._assert_dest(b"/base/foo/the title", self.i1)
+ self._assert_dest(b"/base/foo/the title", i1)
-class PluginDestinationTest(BeetsTestCase):
- def setUp(self):
- super().setUp()
-
+class TestPluginDestination(TestHelper):
+ @pytest.fixture(autouse=True)
+ def item(self, setup):
# Mock beets.plugins.item_field_getters.
self._tv_map = {}
@@ -909,220 +928,212 @@ class PluginDestinationTest(BeetsTestCase):
self.lib.directory = b"/base"
self.lib.path_formats = [("default", "$artist $foo")]
- self.i = item(self.lib)
- def tearDown(self):
- super().tearDown()
+ yield _common.item(self.lib)
+
plugins.item_field_getters = self.old_field_getters
- def _assert_dest(self, dest):
+ def _assert_dest(self, dest, item):
with _common.platform_posix():
- the_dest = self.i.destination()
+ the_dest = item.destination()
assert the_dest == b"/base/" + dest
- def test_undefined_value_not_substituted(self):
- self._assert_dest(b"the artist $foo")
+ def test_undefined_value_not_substituted(self, item):
+ self._assert_dest(b"the artist $foo", item)
- def test_plugin_value_not_substituted(self):
+ def test_plugin_value_not_substituted(self, item):
self._tv_map = {"foo": "bar"}
- self._assert_dest(b"the artist bar")
+ self._assert_dest(b"the artist bar", item)
- def test_plugin_value_overrides_attribute(self):
+ def test_plugin_value_overrides_attribute(self, item):
self._tv_map = {"artist": "bar"}
- self._assert_dest(b"bar $foo")
+ self._assert_dest(b"bar $foo", item)
- def test_plugin_value_sanitized(self):
+ def test_plugin_value_sanitized(self, item):
self._tv_map = {"foo": "bar/baz"}
- self._assert_dest(b"the artist bar_baz")
+ self._assert_dest(b"the artist bar_baz", item)
-class AlbumInfoTest(BeetsTestCase):
- def setUp(self):
- super().setUp()
- self.i = item()
- self.lib.add_album((self.i,))
+class TestAlbumInfo(PytestItemHelper):
+ @pytest.fixture
+ def item_in_album(self, item):
+ self.lib.add_album((item,))
+ return item
- def test_albuminfo_reflects_metadata(self):
- ai = self.lib.get_album(self.i)
- assert ai.mb_albumartistid == self.i.mb_albumartistid
- assert ai.albumartist == self.i.albumartist
- assert ai.album == self.i.album
- assert ai.year == self.i.year
+ def test_albuminfo_reflects_metadata(self, item_in_album):
+ ai = self.lib.get_album(item_in_album)
+ assert ai.mb_albumartistid == item_in_album.mb_albumartistid
+ assert ai.albumartist == item_in_album.albumartist
+ assert ai.album == item_in_album.album
+ assert ai.year == item_in_album.year
- def test_albuminfo_stores_art(self):
- ai = self.lib.get_album(self.i)
+ def test_albuminfo_stores_art(self, item_in_album):
+ ai = self.lib.get_album(item_in_album)
ai.artpath = os.fsdecode(np("/my/great/art"))
ai.store()
- new_ai = self.lib.get_album(self.i)
+ new_ai = self.lib.get_album(item_in_album)
assert new_ai.artpath == np("/my/great/art")
- def test_albuminfo_for_two_items_doesnt_duplicate_row(self):
- i2 = item(self.lib)
- self.lib.get_album(self.i)
+ def test_albuminfo_for_two_items_doesnt_duplicate_row(self, item_in_album):
+ i2 = _common.item(self.lib)
+ self.lib.get_album(item_in_album)
self.lib.get_album(i2)
c = self.lib._connection().cursor()
- c.execute("select * from albums where album=?", (self.i.album,))
+ c.execute("select * from albums where album=?", (item_in_album.album,))
# Cursor should only return one row.
assert c.fetchone() is not None
assert c.fetchone() is None
def test_individual_tracks_have_no_albuminfo(self):
- i2 = item()
+ i2 = _common.item()
i2.album = "aTotallyDifferentAlbum"
self.lib.add(i2)
ai = self.lib.get_album(i2)
assert ai is None
- def test_get_album_by_id(self):
- ai = self.lib.get_album(self.i)
- ai = self.lib.get_album(self.i.id)
+ def test_get_album_by_id(self, item_in_album):
+ ai = self.lib.get_album(item_in_album)
+ ai = self.lib.get_album(item_in_album.id)
assert ai is not None
- def test_album_items_consistent(self):
- ai = self.lib.get_album(self.i)
+ def test_album_items_consistent(self, item_in_album):
+ ai = self.lib.get_album(item_in_album)
for i in ai.items():
- if i.id == self.i.id:
+ if i.id == item_in_album.id:
break
else:
self.fail("item not found")
- def test_albuminfo_changes_affect_items(self):
- ai = self.lib.get_album(self.i)
+ def test_albuminfo_changes_affect_items(self, item_in_album):
+ ai = self.lib.get_album(item_in_album)
ai.album = "myNewAlbum"
ai.store()
- i = self.lib.items()[0]
- assert i.album == "myNewAlbum"
+ assert self.get_first_item().album == "myNewAlbum"
- def test_albuminfo_change_albumartist_changes_items(self):
- ai = self.lib.get_album(self.i)
+ def test_albuminfo_change_albumartist_changes_items(self, item_in_album):
+ ai = self.lib.get_album(item_in_album)
ai.albumartist = "myNewArtist"
ai.store()
- i = self.lib.items()[0]
- assert i.albumartist == "myNewArtist"
- assert i.artist != "myNewArtist"
+ item = self.get_first_item()
+ assert item.albumartist == "myNewArtist"
+ assert item.artist != "myNewArtist"
- def test_albuminfo_change_artist_does_change_items(self):
- ai = self.lib.get_album(self.i)
+ def test_albuminfo_change_artist_does_change_items(self, item_in_album):
+ ai = self.lib.get_album(item_in_album)
ai.artist = "myNewArtist"
ai.store(inherit=True)
- i = self.lib.items()[0]
- assert i.artist == "myNewArtist"
+ assert self.get_first_item().artist == "myNewArtist"
- def test_albuminfo_change_artist_does_not_change_items(self):
- ai = self.lib.get_album(self.i)
+ def test_albuminfo_change_artist_does_not_change_items(self, item_in_album):
+ ai = self.lib.get_album(item_in_album)
ai.artist = "myNewArtist"
ai.store(inherit=False)
- i = self.lib.items()[0]
- assert i.artist != "myNewArtist"
+ assert self.get_first_item().artist != "myNewArtist"
- def test_albuminfo_remove_removes_items(self):
- item_id = self.i.id
- self.lib.get_album(self.i).remove()
+ def test_albuminfo_remove_removes_items(self, item_in_album):
+ item_id = item_in_album.id
+ self.lib.get_album(item_in_album).remove()
c = self.lib._connection().execute(
"SELECT id FROM items WHERE id=?", (item_id,)
)
assert c.fetchone() is None
- def test_removing_last_item_removes_album(self):
+ def test_removing_last_item_removes_album(self, item_in_album):
assert len(self.lib.albums()) == 1
- self.i.remove()
+ item_in_album.remove()
assert len(self.lib.albums()) == 0
- def test_noop_albuminfo_changes_affect_items(self):
- i = self.lib.items()[0]
- i.album = "foobar"
- i.store()
- ai = self.lib.get_album(self.i)
+ def test_noop_albuminfo_changes_affect_items(self, item_in_album):
+ item = self.get_first_item()
+ item.album = "foobar"
+ item.store()
+ ai = self.lib.get_album(item_in_album)
ai.album = ai.album
ai.store()
- i = self.lib.items()[0]
- assert i.album == ai.album
+ item = self.get_first_item()
+ assert item.album == ai.album
-class ArtDestinationTest(BeetsTestCase):
- def setUp(self):
- super().setUp()
+class TestArtDestination(TestHelper):
+ @pytest.fixture(autouse=True)
+ def item_and_album(self, setup):
config["art_filename"] = "artimage"
config["replace"] = {"X": "Y"}
self.lib.replacements = [(re.compile("X"), "Y")]
self.lib.path_formats = [("default", "$artist/$album/$track $title")]
- self.i = item(self.lib)
- self.i.path = self.i.destination()
- self.ai = self.lib.add_album((self.i,))
+ item = _common.item(self.lib)
+ item.path = item.destination()
+ ai = self.lib.add_album((item,))
+ return item, ai
- def test_art_filename_respects_setting(self):
- art = self.ai.art_destination("something.jpg")
+ def test_art_filename_respects_setting(self, item_and_album):
+ _i, ai = item_and_album
+ art = ai.art_destination("something.jpg")
new_art = bytestring_path(f"{os.path.sep}artimage.jpg")
assert new_art in art
- def test_art_path_in_item_dir(self):
- art = self.ai.art_destination("something.jpg")
- track = self.i.destination()
+ def test_art_path_in_item_dir(self, item_and_album):
+ i, ai = item_and_album
+ art = ai.art_destination("something.jpg")
+ track = i.destination()
assert os.path.dirname(art) == os.path.dirname(track)
- def test_art_path_sanitized(self):
+ def test_art_path_sanitized(self, item_and_album):
+ _i, ai = item_and_album
config["art_filename"] = "artXimage"
- art = self.ai.art_destination("something.jpg")
+ art = ai.art_destination("something.jpg")
assert b"artYimage" in art
-class PathStringTest(BeetsTestCase):
- def setUp(self):
- super().setUp()
- self.i = item(self.lib)
+class TestPathString(PytestItemHelper):
+ def test_item_path_is_bytestring(self, item_in_db):
+ assert isinstance(item_in_db.path, bytes)
- def test_item_path_is_bytestring(self):
- assert isinstance(self.i.path, bytes)
+ def test_fetched_item_path_is_bytestring(self, item_in_db):
+ assert isinstance(self.get_first_item().path, bytes)
- def test_fetched_item_path_is_bytestring(self):
- i = next(iter(self.lib.items()))
- assert isinstance(i.path, bytes)
+ def test_unicode_path_becomes_bytestring(self, item_in_db):
+ item_in_db.path = "unicodepath"
+ assert isinstance(item_in_db.path, bytes)
- def test_unicode_path_becomes_bytestring(self):
- self.i.path = "unicodepath"
- assert isinstance(self.i.path, bytes)
-
- def test_unicode_in_database_becomes_bytestring(self):
+ def test_unicode_in_database_becomes_bytestring(self, item_in_db):
self.lib._connection().execute(
"""
update items set path=? where id=?
""",
- (self.i.id, "somepath"),
+ (item_in_db.id, "somepath"),
)
- i = next(iter(self.lib.items()))
- assert isinstance(i.path, bytes)
+ assert isinstance(self.get_first_item().path, bytes)
- def test_special_chars_preserved_in_database(self):
+ def test_special_chars_preserved_in_database(self, item_in_db):
path = "b\xe1r".encode()
- self.i.path = path
- self.i.store()
- i = next(iter(self.lib.items()))
- assert i.path == os.path.join(self.libdir, path)
+ item_in_db.path = path
+ item_in_db.store()
+ assert self.get_first_item().path == os.path.join(self.libdir, path)
- def test_special_char_path_added_to_database(self):
- self.i.remove()
+ def test_special_char_path_added_to_database(self, item, item_in_db):
+ item_in_db.remove()
path = "b\xe1r".encode()
- i = item()
- i.path = path
- self.lib.add(i)
- i = next(iter(self.lib.items()))
- assert i.path == os.path.join(self.libdir, path)
+ item = _common.item()
+ item.path = path
+ self.lib.add(item)
+ assert self.get_first_item().path == os.path.join(self.libdir, path)
- def test_destination_returns_bytestring(self):
- self.i.artist = "b\xe1r"
- dest = self.i.destination()
+ def test_destination_returns_bytestring(self, item_in_db):
+ item_in_db.artist = "b\xe1r"
+ dest = item_in_db.destination()
assert isinstance(dest, bytes)
- def test_art_destination_returns_bytestring(self):
- self.i.artist = "b\xe1r"
- alb = self.lib.add_album([self.i])
+ def test_art_destination_returns_bytestring(self, item_in_db):
+ item_in_db.artist = "b\xe1r"
+ alb = self.lib.add_album([item_in_db])
dest = alb.art_destination("image.jpg")
assert isinstance(dest, bytes)
- def test_artpath_stores_special_chars(self):
+ def test_artpath_stores_special_chars(self, item_in_db):
path = bytestring_path("b\xe1r")
- alb = self.lib.add_album([self.i])
+ alb = self.lib.add_album([item_in_db])
alb.artpath = path
alb.store()
stored_path = (
@@ -1130,7 +1141,7 @@ class PathStringTest(BeetsTestCase):
.execute("select artpath from albums where id=?", (alb.id,))
.fetchone()[0]
)
- alb = self.lib.get_album(self.i)
+ alb = self.lib.get_album(item_in_db)
assert stored_path == path
assert alb.artpath == os.path.join(self.libdir, path)
@@ -1144,74 +1155,72 @@ class PathStringTest(BeetsTestCase):
new_path = util.sanitize_path(path)
assert isinstance(new_path, str)
- def test_unicode_artpath_becomes_bytestring(self):
- alb = self.lib.add_album([self.i])
+ def test_unicode_artpath_becomes_bytestring(self, item_in_db):
+ alb = self.lib.add_album([item_in_db])
alb.artpath = "somep\xe1th"
assert isinstance(alb.artpath, bytes)
- def test_unicode_artpath_in_database_decoded(self):
- alb = self.lib.add_album([self.i])
+ def test_unicode_artpath_in_database_decoded(self, item_in_db):
+ alb = self.lib.add_album([item_in_db])
self.lib._connection().execute(
"update albums set artpath=? where id=?", ("somep\xe1th", alb.id)
)
alb = self.lib.get_album(alb.id)
assert isinstance(alb.artpath, bytes)
- def test_relative_path_is_stored(self):
+ def test_relative_path_is_stored(self, item_in_db):
relative_path = os.path.join(b"abc", b"foo.mp3")
absolute_path = os.path.join(self.libdir, relative_path)
- self.i.path = absolute_path
- self.i.store()
+ item_in_db.path = absolute_path
+ item_in_db.store()
stored_path = (
self.lib._connection()
- .execute("select path from items where id=?", (self.i.id,))
+ .execute("select path from items where id=?", (item_in_db.id,))
.fetchone()[0]
)
- album = self.lib.add_album([self.i])
+ album = self.lib.add_album([item_in_db])
- assert self.i.path == absolute_path
+ assert item_in_db.path == absolute_path
assert stored_path == path_as_posix(relative_path)
assert album.path == os.path.dirname(absolute_path)
-class MtimeTest(BeetsTestCase):
- def setUp(self):
- super().setUp()
+class TestMtime(TestHelper):
+ @pytest.fixture(autouse=True)
+ def item(self, setup):
self.ipath = os.path.join(self.temp_dir, b"testfile.mp3")
shutil.copy(
syspath(os.path.join(_common.RSRC, b"full.mp3")),
syspath(self.ipath),
)
- self.i = beets.library.Item.from_path(self.ipath)
- self.lib.add(self.i)
-
- def tearDown(self):
- super().tearDown()
+ item = beets.library.Item.from_path(self.ipath)
+ self.lib.add(item)
+ yield item
if os.path.exists(self.ipath):
os.remove(self.ipath)
def _mtime(self):
return int(os.path.getmtime(self.ipath))
- def test_mtime_initially_up_to_date(self):
- assert self.i.mtime >= self._mtime()
+ def test_mtime_initially_up_to_date(self, item):
+ assert item.mtime >= self._mtime()
- def test_mtime_reset_on_db_modify(self):
- self.i.title = "something else"
- assert self.i.mtime < self._mtime()
+ def test_mtime_reset_on_db_modify(self, item):
+ item.title = "something else"
+ assert item.mtime < self._mtime()
- def test_mtime_up_to_date_after_write(self):
- self.i.title = "something else"
- self.i.write()
- assert self.i.mtime >= self._mtime()
+ def test_mtime_up_to_date_after_write(self, item):
+ item.title = "something else"
+ item.write()
+ assert item.mtime >= self._mtime()
- def test_mtime_up_to_date_after_read(self):
- self.i.title = "something else"
- self.i.read()
- assert self.i.mtime >= self._mtime()
+ def test_mtime_up_to_date_after_read(self, item):
+ item.title = "something else"
+ item.read()
+ assert item.mtime >= self._mtime()
-class ImportTimeTest(BeetsTestCase):
+class TestImportTime(TestHelper):
def added(self):
self.track = item()
self.album = self.lib.add_album((self.track,))
@@ -1223,19 +1232,19 @@ class ImportTimeTest(BeetsTestCase):
assert self.singleton.added > 0
-class TemplateTest(ItemInDBTestCase):
- def test_year_formatted_in_template(self):
- self.i.year = 123
- self.i.store()
- assert self.i.evaluate_template("$year") == "0123"
+class TestTemplate(PytestItemHelper):
+ def test_year_formatted_in_template(self, item_in_db):
+ item_in_db.year = 123
+ item_in_db.store()
+ assert item_in_db.evaluate_template("$year") == "0123"
- def test_album_flexattr_appears_in_item_template(self):
- self.album = self.lib.add_album([self.i])
+ def test_album_flexattr_appears_in_item_template(self, item_in_db):
+ self.album = self.lib.add_album([item_in_db])
self.album.foo = "baz"
self.album.store()
- assert self.i.evaluate_template("$foo") == "baz"
+ assert item_in_db.evaluate_template("$foo") == "baz"
- def test_album_and_item_format(self):
+ def test_album_and_item_format(self, item_in_db):
config["format_album"] = "foƶ $foo"
album = beets.library.Album()
album.foo = "bar"
@@ -1253,16 +1262,18 @@ class TemplateTest(ItemInDBTestCase):
assert f"{item:$tagada}" == "togodo"
-class UnicodePathTest(ItemInDBTestCase):
- def test_unicode_path(self):
- self.i.path = os.path.join(_common.RSRC, "unicode\u2019d.mp3".encode())
+class TestUnicodePath(PytestItemHelper):
+ def test_unicode_path(self, item_in_db):
+ item_in_db.path = os.path.join(
+ _common.RSRC, "unicode\u2019d.mp3".encode()
+ )
# If there are any problems with unicode paths, we will raise
# here and fail.
- self.i.read()
- self.i.write()
+ item_in_db.read()
+ item_in_db.write()
-class WriteTest(BeetsTestCase):
+class TestWrite(TestHelper):
def test_write_nonexistant(self):
item = self.create_item()
item.path = b"/path/does/not/exist"
@@ -1330,30 +1341,27 @@ class WriteTest(BeetsTestCase):
assert MediaFile(syspath(item.path)).year == clean_year
-class ItemReadTest(unittest.TestCase):
- def test_unreadable_raise_read_error(self):
+class TestItemRead(PytestItemHelper):
+ def test_unreadable_raise_read_error(self, item_in_db):
unreadable = os.path.join(_common.RSRC, b"image-2x3.png")
- item = beets.library.Item()
with pytest.raises(beets.library.ReadError) as exc_info:
- item.read(unreadable)
+ item_in_db.read(unreadable)
assert isinstance(exc_info.value.reason, UnreadableFileError)
- def test_nonexistent_raise_read_error(self):
- item = beets.library.Item()
+ def test_nonexistent_raise_read_error(self, item_in_db):
with pytest.raises(beets.library.ReadError):
- item.read("/thisfiledoesnotexist")
+ item_in_db.read("/thisfiledoesnotexist")
- def test_read_error_str_includes_reason(self):
+ def test_read_error_str_includes_reason(self, item_in_db):
unreadable = os.path.join(_common.RSRC, b"image-2x3.png")
- item = beets.library.Item()
with pytest.raises(beets.library.ReadError) as exc_info:
- item.read(unreadable)
+ item_in_db.read(unreadable)
message = str(exc_info.value)
assert "super:" not in message
assert str(exc_info.value.reason) in message
-class ItemReadGenreTest(BeetsTestCase):
+class TestItemReadGenre(TestHelper):
def test_read_semicolon_delimited_genres(self):
"""Semicolon-delimited genre tags are split into individual genres on read."""
path = self.create_mediafile_fixture()
@@ -1364,7 +1372,7 @@ class ItemReadGenreTest(BeetsTestCase):
assert item.genres == ["Jazz", "Funk", "Soul"]
-class FilesizeTest(BeetsTestCase):
+class TestFilesize(TestHelper):
def test_filesize(self):
item = self.add_item_fixture()
assert item.filesize != 0
@@ -1374,7 +1382,7 @@ class FilesizeTest(BeetsTestCase):
assert item.filesize == 0
-class ItemPruneDirsClutterTest(BeetsTestCase):
+class TestItemPruneDirsClutter(TestHelper):
"""Regression tests: prune_dirs respects config["clutter"] during move/remove."""
def _drop_clutter(self, directory, filename=b"unwanted.log"):
@@ -1410,7 +1418,7 @@ class ItemPruneDirsClutterTest(BeetsTestCase):
assert not os.path.exists(syspath(old_dir))
-class ParseQueryTest(unittest.TestCase):
+class TestParseQuery:
def test_parse_invalid_query_string(self):
with pytest.raises(beets.dbcore.query.ParsingError):
beets.library.parse_query_string('foo"', None)
diff --git a/test/test_plugins.py b/test/test_plugins.py
index 7bbf07839..306c48d70 100644
--- a/test/test_plugins.py
+++ b/test/test_plugins.py
@@ -39,13 +39,13 @@ from beets.test.helper import (
ImportHelper,
IOMixin,
PluginMixin,
- PytestPluginTestHelper,
+ PluginTestHelper,
TerminalImportMixin,
)
from beets.util import PromptChoice, displayable_path, syspath
-class TestPluginRegistration(IOMixin, PytestPluginTestHelper):
+class TestPluginRegistration(IOMixin, PluginTestHelper):
class RatingPlugin(plugins.BeetsPlugin):
item_types: ClassVar[dict[str, types.Type]] = {
"rating": types.Float(),
@@ -97,7 +97,7 @@ class TestPluginRegistration(IOMixin, PytestPluginTestHelper):
assert out == "one; two; three\n"
-class PytestImportHelper(ImportHelper, PytestPluginTestHelper):
+class PytestImportHelper(ImportHelper, PluginTestHelper):
@pytest.fixture(autouse=True)
def setup_import_helper(self, setup):
self.import_media = []
@@ -179,7 +179,7 @@ class TestEvents(PytestImportHelper):
]
-class TestListeners(PytestPluginTestHelper):
+class TestListeners(PluginTestHelper):
def test_register(self):
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):
diff --git a/test/ui/commands/test_import.py b/test/ui/commands/test_import.py
index d9759b71c..dbc3be41f 100644
--- a/test/ui/commands/test_import.py
+++ b/test/ui/commands/test_import.py
@@ -5,8 +5,7 @@ from unittest.mock import Mock, patch
import pytest
from beets import config, library
-from beets.autotag.hooks import AlbumInfo, AlbumMatch, TrackInfo
-from beets.autotag.match import distance
+from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo, distance
from beets.exceptions import UserError
from beets.test import _common
from beets.test.helper import BeetsTestCase, IOMixin