From 6e2ed46ad41ffca28e2ca689d15f3e6e4e24234a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 18 Jun 2026 01:36:18 +0100 Subject: [PATCH] Move AttrDict under beets.util --- beets/autotag/__init__.py | 3 +-- beets/autotag/hooks.py | 53 ++------------------------------------ beets/util/__init__.py | 45 ++++++++++++++++++++++++++++++++ test/autotag/test_hooks.py | 2 +- 4 files changed, 49 insertions(+), 54 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index bdb2ee903..6cd193ad6 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -8,7 +8,7 @@ from importlib import import_module from beets.util.deprecation import deprecate_for_maintainers, deprecate_imports from .distance import Distance, distance, string_dist, track_distance -from .hooks import AlbumInfo, AttrDict, Info, TrackInfo, correct_list_fields +from .hooks import AlbumInfo, Info, TrackInfo, correct_list_fields from .match import ( AlbumMatch, Match, @@ -34,7 +34,6 @@ def __getattr__(name: str): __all__ = [ "AlbumInfo", "AlbumMatch", - "AttrDict", "Distance", "Info", "Match", diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 4a771e238..ceba6f6bf 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -4,20 +4,16 @@ from __future__ import annotations from copy import deepcopy from functools import cached_property -from typing import Any, ClassVar, TypeVar - -from typing_extensions import Self +from typing import Any, ClassVar from beets import config, logging -from beets.util import cached_classproperty, unique_list +from beets.util import AttrDict, cached_classproperty, unique_list from beets.util.deprecation import ( ALBUM_LEGACY_TO_LIST_FIELD, ITEM_LEGACY_TO_LIST_FIELD, deprecate_for_maintainers, ) -V = TypeVar("V") - JSONDict = dict[str, Any] log = logging.getLogger("beets") @@ -87,51 +83,6 @@ def correct_list_fields(input_data: JSONDict) -> JSONDict: # Classes used to represent candidate options. -class AttrDict(dict[str, V]): - """Mapping enabling attribute-style access to stored metadata values.""" - - def copy(self) -> Self: - """Return a detached copy preserving subclass-specific behavior.""" - return deepcopy(self) - - def __getattribute__(self, attr: str) -> V: - # Intercept cached_property failures so an AttributeError raised - # inside the property body is not masked by __getattr__ fallback. - # Reuse the original traceback so the wrapped RuntimeError still - # points at the real failing line, but suppress the printed cause - # block to keep CLI tracebacks readable. See #6558 (and #6503 / - # #6506 for the same masking pattern with different metadata - # providers). - try: - return super().__getattribute__(attr) - except AttributeError as exc: - if not attr.startswith("__"): - for klass in type(self).__mro__: - descr = klass.__dict__.get(attr) - if descr is None: - continue - if isinstance(descr, cached_property): - raise RuntimeError( - f"{type(self).__name__}.{attr} failed: {exc}" - ).with_traceback(exc.__traceback__) from None - break - raise - - def __getattr__(self, attr: str) -> V: - if attr in self: - return self[attr] - - raise AttributeError( - f"'{self.__class__.__name__}' object has no attribute '{attr}'" - ) - - def __setattr__(self, key: str, value: V) -> None: - self.__setitem__(key, value) - - def __hash__(self) -> int: # type: ignore[override] - return id(self) - - class Info(AttrDict[Any]): """Container for metadata about a musical entity.""" diff --git a/beets/util/__init__.py b/beets/util/__init__.py index fd998ddef..39255206d 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -1199,3 +1199,48 @@ def chunks(lst: Sequence[T], n: int) -> Iterator[list[T]]: """Yield successive n-sized chunks from lst.""" for i in range(0, len(lst), n): yield list(lst[i : i + n]) + + +class AttrDict(dict[str, T]): + """Mapping enabling attribute-style access to stored metadata values.""" + + def copy(self) -> Self: + """Return a detached copy preserving subclass-specific behavior.""" + return deepcopy(self) + + def __getattribute__(self, attr: str) -> T: + # Intercept cached_property failures so an AttributeError raised + # inside the property body is not masked by __getattr__ fallback. + # Reuse the original traceback so the wrapped RuntimeError still + # points at the real failing line, but suppress the printed cause + # block to keep CLI tracebacks readable. See #6558 (and #6503 / + # #6506 for the same masking pattern with different metadata + # providers). + try: + return super().__getattribute__(attr) + except AttributeError as exc: + if not attr.startswith("__"): + for klass in type(self).__mro__: + descr = klass.__dict__.get(attr) + if descr is None: + continue + if isinstance(descr, cached_property): + raise RuntimeError( + f"{type(self).__name__}.{attr} failed: {exc}" + ).with_traceback(exc.__traceback__) from None + break + raise + + def __getattr__(self, attr: str) -> T: + if attr in self: + return self[attr] + + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{attr}'" + ) + + def __setattr__(self, key: str, value: T) -> None: + self.__setitem__(key, value) + + def __hash__(self) -> int: # type: ignore[override] + return id(self) diff --git a/test/autotag/test_hooks.py b/test/autotag/test_hooks.py index 0c3e78aa0..c58523ab7 100644 --- a/test/autotag/test_hooks.py +++ b/test/autotag/test_hooks.py @@ -9,7 +9,6 @@ import pytest from beets.autotag import ( AlbumInfo, AlbumMatch, - AttrDict, Distance, TrackInfo, TrackMatch, @@ -17,6 +16,7 @@ from beets.autotag import ( ) from beets.library import Item from beets.test.helper import BeetsTestCase +from beets.util import AttrDict str_field_deprecation = pytest.warns( DeprecationWarning, match="The 'genre' field is deprecated"