Create common info class (#5963)

### Context
See https://github.com/beetbox/beets/pull/5916 where we've come across a
need to define common logic between `TrackInfo` and `AlbumInfo`.

### Changes
- Introduce generic `Info` base (extends `AttrDict`) used by `AlbumInfo`
/ `TrackInfo` to centralize shared attributes and initialisation logic.
- Sort keyword parameters in each constructor alphabetically and make
them explicit.
- Deduplicate and simplify shared `copy()` method using `copy.deepcopy`
- Improve type hints and documentation.
- Drop unused logging artifacts.

## Summary by Sourcery

Refactor metadata-handling classes by extracting common functionality
into a new Info base, updating AlbumInfo and TrackInfo to extend it with
explicit sorted parameters, unify their copy logic, improve type
annotations and docs, and drop obsolete logging code

New Features:
- Introduce a generic Info base class to centralize shared logic for
AlbumInfo and TrackInfo

Enhancements:
- Alphabetically sort and explicitly declare constructor keyword
parameters for consistency
- Unify and simplify the copy() implementation in AttrDict using
deepcopy
- Enhance type hints and documentation for metadata classes

Chores:
- Remove unused logging imports and artifacts
This commit is contained in:
Šarūnas Nejus 2025-09-08 14:34:18 +01:00 committed by GitHub
commit f24beca085
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -16,236 +16,201 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
from beets import logging from typing_extensions import Self
if TYPE_CHECKING: if TYPE_CHECKING:
from beets.library import Item from beets.library import Item
from .distance import Distance from .distance import Distance
log = logging.getLogger("beets")
V = TypeVar("V") V = TypeVar("V")
# Classes used to represent candidate options. # Classes used to represent candidate options.
class AttrDict(dict[str, V]): class AttrDict(dict[str, V]):
"""A dictionary that supports attribute ("dot") access, so `d.field` """Mapping enabling attribute-style access to stored metadata values."""
is equivalent to `d['field']`.
""" def copy(self) -> Self:
return deepcopy(self)
def __getattr__(self, attr: str) -> V: def __getattr__(self, attr: str) -> V:
if attr in self: if attr in self:
return self[attr] return self[attr]
else:
raise AttributeError
def __setattr__(self, key: str, value: V): raise AttributeError(
f"'{self.__class__.__name__}' object has no attribute '{attr}'"
)
def __setattr__(self, key: str, value: V) -> None:
self.__setitem__(key, value) self.__setitem__(key, value)
def __hash__(self): def __hash__(self) -> int: # type: ignore[override]
return id(self) return id(self)
class AlbumInfo(AttrDict[Any]): class Info(AttrDict[Any]):
"""Describes a canonical release that may be used to match a release """Container for metadata about a musical entity."""
in the library. Consists of these data members:
- ``album``: the release title def __init__(
- ``album_id``: MusicBrainz ID; UUID fragment only self,
- ``artist``: name of the release's primary artist album: str | None = None,
- ``artist_id`` artist_credit: str | None = None,
- ``tracks``: list of TrackInfo objects making up the release artist_id: str | None = None,
artist: str | None = None,
artists_credit: list[str] | None = None,
artists_ids: list[str] | None = None,
artists: list[str] | None = None,
artist_sort: str | None = None,
artists_sort: list[str] | None = None,
data_source: str | None = None,
data_url: str | None = None,
genre: str | None = None,
media: str | None = None,
**kwargs,
) -> None:
self.album = album
self.artist = artist
self.artist_credit = artist_credit
self.artist_id = artist_id
self.artists = artists or []
self.artists_credit = artists_credit or []
self.artists_ids = artists_ids or []
self.artist_sort = artist_sort
self.artists_sort = artists_sort or []
self.data_source = data_source
self.data_url = data_url
self.genre = genre
self.media = media
self.update(kwargs)
``mediums`` along with the fields up through ``tracks`` are required.
The others are optional and may be None. class AlbumInfo(Info):
"""Metadata snapshot representing a single album candidate.
Aggregates track entries and album-wide context gathered from an external
provider. Used during matching to evaluate similarity against a group of
user items, and later to drive tagging decisions once selected.
""" """
# TYPING: are all of these correct? I've assumed optional strings
def __init__( def __init__(
self, self,
tracks: list[TrackInfo], tracks: list[TrackInfo],
album: str | None = None, *,
album_id: str | None = None, album_id: str | None = None,
artist: str | None = None, albumdisambig: str | None = None,
artist_id: str | None = None, albumstatus: str | None = None,
artists: list[str] | None = None,
artists_ids: list[str] | None = None,
asin: str | None = None,
albumtype: str | None = None, albumtype: str | None = None,
albumtypes: list[str] | None = None, albumtypes: list[str] | None = None,
asin: str | None = None,
barcode: str | None = None,
catalognum: str | None = None,
country: str | None = None,
day: int | None = None,
discogs_albumid: str | None = None,
discogs_artistid: str | None = None,
discogs_labelid: str | None = None,
label: str | None = None,
language: str | None = None,
mediums: int | None = None,
month: int | None = None,
original_day: int | None = None,
original_month: int | None = None,
original_year: int | None = None,
release_group_title: str | None = None,
releasegroup_id: str | None = None,
releasegroupdisambig: str | None = None,
script: str | None = None,
style: str | None = None,
va: bool = False, va: bool = False,
year: int | None = None, year: int | None = None,
month: int | None = None,
day: int | None = None,
label: str | None = None,
barcode: str | None = None,
mediums: int | None = None,
artist_sort: str | None = None,
artists_sort: list[str] | None = None,
releasegroup_id: str | None = None,
release_group_title: str | None = None,
catalognum: str | None = None,
script: str | None = None,
language: str | None = None,
country: str | None = None,
style: str | None = None,
genre: str | None = None,
albumstatus: str | None = None,
media: str | None = None,
albumdisambig: str | None = None,
releasegroupdisambig: str | None = None,
artist_credit: str | None = None,
artists_credit: list[str] | None = None,
original_year: int | None = None,
original_month: int | None = None,
original_day: int | None = None,
data_source: str | None = None,
data_url: str | None = None,
discogs_albumid: str | None = None,
discogs_labelid: str | None = None,
discogs_artistid: str | None = None,
**kwargs, **kwargs,
): ) -> None:
self.album = album
self.album_id = album_id
self.artist = artist
self.artist_id = artist_id
self.artists = artists or []
self.artists_ids = artists_ids or []
self.tracks = tracks self.tracks = tracks
self.asin = asin self.album_id = album_id
self.albumdisambig = albumdisambig
self.albumstatus = albumstatus
self.albumtype = albumtype self.albumtype = albumtype
self.albumtypes = albumtypes or [] self.albumtypes = albumtypes or []
self.asin = asin
self.barcode = barcode
self.catalognum = catalognum
self.country = country
self.day = day
self.discogs_albumid = discogs_albumid
self.discogs_artistid = discogs_artistid
self.discogs_labelid = discogs_labelid
self.label = label
self.language = language
self.mediums = mediums
self.month = month
self.original_day = original_day
self.original_month = original_month
self.original_year = original_year
self.release_group_title = release_group_title
self.releasegroup_id = releasegroup_id
self.releasegroupdisambig = releasegroupdisambig
self.script = script
self.style = style
self.va = va self.va = va
self.year = year self.year = year
self.month = month super().__init__(**kwargs)
self.day = day
self.label = label
self.barcode = barcode
self.mediums = mediums
self.artist_sort = artist_sort
self.artists_sort = artists_sort or []
self.releasegroup_id = releasegroup_id
self.release_group_title = release_group_title
self.catalognum = catalognum
self.script = script
self.language = language
self.country = country
self.style = style
self.genre = genre
self.albumstatus = albumstatus
self.media = media
self.albumdisambig = albumdisambig
self.releasegroupdisambig = releasegroupdisambig
self.artist_credit = artist_credit
self.artists_credit = artists_credit or []
self.original_year = original_year
self.original_month = original_month
self.original_day = original_day
self.data_source = data_source
self.data_url = data_url
self.discogs_albumid = discogs_albumid
self.discogs_labelid = discogs_labelid
self.discogs_artistid = discogs_artistid
self.update(kwargs)
def copy(self) -> AlbumInfo:
dupe = AlbumInfo([])
dupe.update(self)
dupe.tracks = [track.copy() for track in self.tracks]
return dupe
class TrackInfo(AttrDict[Any]): class TrackInfo(Info):
"""Describes a canonical track present on a release. Appears as part """Metadata snapshot for a single track candidate.
of an AlbumInfo's ``tracks`` list. Consists of these data members:
- ``title``: name of the track Captures identifying details and creative credits used to compare against
- ``track_id``: MusicBrainz ID; UUID fragment only a user's item. Instances often originate within an AlbumInfo but may also
stand alone for singleton matching.
Only ``title`` and ``track_id`` are required. The rest of the fields
may be None. The indices ``index``, ``medium``, and ``medium_index``
are all 1-based.
""" """
# TYPING: are all of these correct? I've assumed optional strings
def __init__( def __init__(
self, self,
title: str | None = None, *,
track_id: str | None = None, arranger: str | None = None,
release_track_id: str | None = None, bpm: str | None = None,
artist: str | None = None, composer: str | None = None,
artist_id: str | None = None, composer_sort: str | None = None,
artists: list[str] | None = None, disctitle: str | None = None,
artists_ids: list[str] | None = None,
length: float | None = None,
index: int | None = None, index: int | None = None,
initial_key: str | None = None,
length: float | None = None,
lyricist: str | None = None,
mb_workid: str | None = None,
medium: int | None = None, medium: int | None = None,
medium_index: int | None = None, medium_index: int | None = None,
medium_total: int | None = None, medium_total: int | None = None,
artist_sort: str | None = None, release_track_id: str | None = None,
artists_sort: list[str] | None = None, title: str | None = None,
disctitle: str | None = None,
artist_credit: str | None = None,
artists_credit: list[str] | None = None,
data_source: str | None = None,
data_url: str | None = None,
media: str | None = None,
lyricist: str | None = None,
composer: str | None = None,
composer_sort: str | None = None,
arranger: str | None = None,
track_alt: str | None = None, track_alt: str | None = None,
track_id: str | None = None,
work: str | None = None, work: str | None = None,
mb_workid: str | None = None,
work_disambig: str | None = None, work_disambig: str | None = None,
bpm: str | None = None,
initial_key: str | None = None,
genre: str | None = None,
album: str | None = None,
**kwargs, **kwargs,
): ) -> None:
self.title = title self.arranger = arranger
self.track_id = track_id self.bpm = bpm
self.release_track_id = release_track_id self.composer = composer
self.artist = artist self.composer_sort = composer_sort
self.artist_id = artist_id self.disctitle = disctitle
self.artists = artists or []
self.artists_ids = artists_ids or []
self.length = length
self.index = index self.index = index
self.media = media self.initial_key = initial_key
self.length = length
self.lyricist = lyricist
self.mb_workid = mb_workid
self.medium = medium self.medium = medium
self.medium_index = medium_index self.medium_index = medium_index
self.medium_total = medium_total self.medium_total = medium_total
self.artist_sort = artist_sort self.release_track_id = release_track_id
self.artists_sort = artists_sort or [] self.title = title
self.disctitle = disctitle
self.artist_credit = artist_credit
self.artists_credit = artists_credit or []
self.data_source = data_source
self.data_url = data_url
self.lyricist = lyricist
self.composer = composer
self.composer_sort = composer_sort
self.arranger = arranger
self.track_alt = track_alt self.track_alt = track_alt
self.track_id = track_id
self.work = work self.work = work
self.mb_workid = mb_workid
self.work_disambig = work_disambig self.work_disambig = work_disambig
self.bpm = bpm super().__init__(**kwargs)
self.initial_key = initial_key
self.genre = genre
self.album = album
self.update(kwargs)
def copy(self) -> TrackInfo:
dupe = TrackInfo()
dupe.update(self)
return dupe
# Structures that compose all the information for a candidate match. # Structures that compose all the information for a candidate match.