Refactor dist display (#6444)

## Refactor: Move display logic into `Distance` and `Match` as
properties

Display-related logic previously scattered across `display.py` and
`session.py` is consolidated into the data classes themselves.

### What changed

**`Distance` gains three properties:**

- `penalties` — list of cleaned-up penalty key strings
- `color` — threshold-based `ColorName` derived from the distance value
- `string` — colorized similarity percentage

**`Match` gains two properties:**

- `disambig_string` — formatted comma-separated disambiguation string
- `base_disambig_data` — override point for subclass-specific field
pre-processing (e.g. `media` for `AlbumMatch`,
`index`/`track_alt`/`album` for `TrackMatch`)

**`display.py` / `session.py`:** Standalone functions `dist_string`,
`dist_colorize`, `penalty_string`, `disambig_string`,
`get_album_disambig_fields`, `get_singleton_disambig_fields` are
removed. Call sites now use the properties directly.

A minor fix in `show_match_header` collects output into a list and uses
`textwrap.indent` for a single `ui.print_` call, replacing the previous
per-line prints.

### Impact

- Display logic lives next to the data it describes — easier to find,
easier to test
- `display.py` and `session.py` become thinner; no shared utility
functions to keep in sync
This commit is contained in:
Šarūnas Nejus 2026-03-20 19:23:54 +00:00 committed by GitHub
commit 03b1ab012c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 133 additions and 161 deletions

View file

@ -10,11 +10,13 @@ from unidecode import unidecode
from beets import config, metadata_plugins
from beets.util import as_string, cached_classproperty, get_most_common_tags
from beets.util.color import colorize
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from beets.library import Item
from beets.util.color import ColorName
from .hooks import AlbumInfo, TrackInfo
@ -139,6 +141,13 @@ class Distance:
weights[key] = weights_view[key].as_number()
return weights
@property
def generic_penalty_keys(self) -> list[str]:
return [
k.replace("album_", "").replace("track_", "").replace("_", " ")
for k in self._penalties
]
# Access the components and their aggregates.
@property
@ -167,6 +176,18 @@ class Distance:
dist_raw += sum(penalty) * self._weights[key]
return dist_raw
@property
def color(self) -> ColorName:
if self.distance <= config["match"]["strong_rec_thresh"].as_number():
return "text_success"
if self.distance <= config["match"]["medium_rec_thresh"].as_number():
return "text_warning"
return "text_error"
@property
def string(self) -> str:
return colorize(self.color, f"{(1 - self.distance) * 100:.1f}%")
def items(self) -> list[tuple[str, float]]:
"""Return a list of (key, dist) pairs, with `dist` being the
weighted distance, sorted from highest to lowest. Does not

View file

@ -19,21 +19,27 @@ from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from functools import cached_property
from typing import TYPE_CHECKING, Any, TypeVar
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
from typing_extensions import Self
from beets import plugins
from beets import config, logging, plugins
from beets.util import cached_classproperty
from beets.util.deprecation import deprecate_for_maintainers
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.library import Item
from .distance import Distance
V = TypeVar("V")
JSONDict = dict[str, Any]
log = logging.getLogger("beets")
# Classes used to represent candidate options.
class AttrDict(dict[str, V]):
@ -268,6 +274,8 @@ class TrackInfo(Info):
# Structures that compose all the information for a candidate match.
@dataclass
class Match:
disambig_fields_key: ClassVar[str]
distance: Distance
info: Info
@ -275,9 +283,38 @@ class Match:
def type(cls) -> str:
return cls.__name__.removesuffix("Match") # type: ignore[attr-defined]
@property
def disambig_fields(self) -> Sequence[str]:
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 {}
@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):
disambig_fields_key = "album_disambig_fields"
info: AlbumInfo
mapping: dict[Item, TrackInfo]
extra_items: list[Item]
@ -295,7 +332,34 @@ class AlbumMatch(Match):
def items(self) -> list[Item]:
return [i for i, _ in self.item_info_pairs]
@property
def base_disambig_data(self) -> JSONDict:
return {
"media": (
f"{mediums}x{self.info.media}"
if (mediums := self.info.mediums) and mediums > 1
else self.info.media
),
}
@dataclass
class TrackMatch(Match):
disambig_fields_key = "singleton_disambig_fields"
info: TrackInfo
@property
def base_disambig_data(self) -> JSONDict:
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 ""
),
}

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import os
import textwrap
from dataclasses import dataclass
from functools import cached_property
from typing import TYPE_CHECKING
@ -8,18 +9,15 @@ from typing import TYPE_CHECKING
from beets import config, ui
from beets.autotag import hooks
from beets.util import displayable_path
from beets.util.color import colorize, dist_colorize
from beets.util.color import colorize
from beets.util.diff import colordiff
from beets.util.layout import Side, get_layout_lines, indent
from beets.util.units import human_seconds_short
if TYPE_CHECKING:
from collections.abc import Sequence
import confuse
from beets import autotag
from beets.autotag.distance import Distance
from beets.library.models import Item
from beets.util.color import ColorName
@ -46,9 +44,13 @@ class ChangeRepresentation:
def _indentation_config(self) -> confuse.Subview:
return config["ui"]["import"]["indentation"]
@cached_property
def indent(self) -> int:
return self._indentation_config["match_header"].get(int)
@cached_property
def indent_header(self) -> str:
return indent(self._indentation_config["match_header"].get(int))
return indent(self.indent)
@cached_property
def indent_detail(self) -> str:
@ -68,33 +70,29 @@ class ChangeRepresentation:
the user accept the match.
"""
# Print newline at beginning of change block.
ui.print_("")
parts = [""]
# 'Match' line and similarity.
ui.print_(
f"{self.indent_header}Match ({dist_string(self.match.distance)}):"
parts.append(f"Match ({self.match.distance.string}):")
parts.append(
ui.colorize(
self.match.distance.color,
f"{self.match.info.artist} - {self.match.info.name}",
)
)
artist_name_str = f"{self.match.info.artist} - {self.match.info.name}"
ui.print_(
self.indent_header
+ dist_colorize(artist_name_str, self.match.distance)
)
if penalty_keys := self.match.distance.generic_penalty_keys:
parts.append(
ui.colorize("changed", f"\u2260 {', '.join(penalty_keys)}")
)
# Penalties.
penalties = penalty_string(self.match.distance)
if penalties:
ui.print_(f"{self.indent_header}{penalties}")
if disambig := self.match.disambig_string:
parts.append(disambig)
# Disambiguation.
disambig = disambig_string(self.match.info)
if disambig:
ui.print_(f"{self.indent_header}{disambig}")
if data_url := self.match.info.data_url:
parts.append(ui.colorize("text_faint", f"{data_url}"))
# Data URL.
if self.match.info.data_url:
url = colorize("text_faint", f"{self.match.info.data_url}")
ui.print_(f"{self.indent_header}{url}")
ui.print_(textwrap.indent("\n".join(parts), self.indent_header))
def show_match_details(self) -> None:
"""Print out the details of the match, including changes in album name
@ -397,97 +395,3 @@ def show_item_change(item: Item, match: hooks.TrackMatch) -> None:
change.show_match_header()
# Print the match details.
change.show_match_details()
def disambig_string(info: hooks.Info) -> str:
"""Generate a string for an AlbumInfo or TrackInfo object that
provides context that helps disambiguate similar-looking albums and
tracks.
"""
if isinstance(info, hooks.AlbumInfo):
disambig = get_album_disambig_fields(info)
elif isinstance(info, hooks.TrackInfo):
disambig = get_singleton_disambig_fields(info)
else:
return ""
return ", ".join(disambig)
def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]:
out = []
chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq()
calculated_values = {
"index": f"Index {info.index}",
"track_alt": f"Track {info.track_alt}",
"album": (
f"[{info.album}]"
if (
config["import"]["singleton_album_disambig"].get()
and info.get("album")
)
else ""
),
}
for field in chosen_fields:
if field in calculated_values:
out.append(str(calculated_values[field]))
else:
try:
out.append(str(info[field]))
except (AttributeError, KeyError):
print(f"Disambiguation string key {field} does not exist.")
return out
def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]:
out = []
chosen_fields = config["match"]["album_disambig_fields"].as_str_seq()
calculated_values = {
"media": (
f"{info.mediums}x{info.media}"
if (info.mediums and info.mediums > 1)
else info.media
),
}
for field in chosen_fields:
if field in calculated_values:
out.append(str(calculated_values[field]))
else:
try:
out.append(str(info[field]))
except (AttributeError, KeyError):
print(f"Disambiguation string key {field} does not exist.")
return out
def dist_string(dist: Distance) -> str:
"""Formats a distance (a float) as a colorized similarity percentage
string.
"""
string = f"{(1 - dist) * 100:.1f}%"
return dist_colorize(string, dist)
def penalty_string(distance: Distance, limit: int | None = None) -> str:
"""Returns a colorized string that indicates all the penalties
applied to a distance object.
"""
penalties = []
for key in distance.keys():
key = key.replace("album_", "")
key = key.replace("track_", "")
key = key.replace("_", " ")
penalties.append(key)
if penalties:
if limit and len(penalties) > limit:
penalties = [*penalties[:limit], "..."]
# Prefix penalty string with U+2260: Not Equal To
penalty_string = f"\u2260 {', '.join(penalties)}"
return colorize("changed", penalty_string)
return ""

View file

@ -1,18 +1,15 @@
from __future__ import annotations
from collections import Counter
from itertools import chain
from beets import autotag, config, importer, logging, plugins, ui
from beets.autotag import Recommendation
from beets.util import PromptChoice, displayable_path
from beets.util.color import colorize, dist_colorize
from beets.util.color import colorize
from beets.util.units import human_bytes, human_seconds_short
from .display import (
disambig_string,
penalty_string,
show_change,
show_item_change,
)
from .display import show_change, show_item_change
# Global logger.
log = logging.getLogger("beets")
@ -439,26 +436,28 @@ def choose_candidate(
ui.print_(" Candidates:")
for i, match in enumerate(candidates):
# Index, metadata, and distance.
index0 = f"{i + 1}."
index = dist_colorize(index0, match.distance)
dist = f"({(1 - match.distance) * 100:.1f}%)"
distance = dist_colorize(dist, match.distance)
metadata = f"{match.info.artist} - {match.info.name}"
if i == 0:
metadata = dist_colorize(metadata, match.distance)
else:
metadata = colorize("text_highlight_minor", metadata)
line1 = [index, distance, metadata]
ui.print_(f" {' '.join(line1)}")
dist_color = match.distance.color
line_parts = [
colorize(dist_color, f"{i + 1}."),
match.distance.string,
colorize(
dist_color if i == 0 else "text_highlight_minor",
f"{match.info.artist} - {match.info.name}",
),
]
ui.print_(f" {' '.join(line_parts)}")
# Penalties.
penalties = penalty_string(match.distance, 3)
if penalties:
ui.print_(f"{' ' * 13}{penalties}")
if penalty_keys := match.distance.generic_penalty_keys:
if len(penalty_keys) > 3:
penalty_keys = [*penalty_keys[:3], "..."]
penalty_text = colorize(
"changed", f"\u2260 {', '.join(penalty_keys)}"
)
ui.print_(f"{' ' * 13}{penalty_text}")
# Disambiguation
disambig = disambig_string(match.info)
if disambig:
if disambig := match.disambig_string:
ui.print_(f"{' ' * 13}{disambig}")
# Ask the user for a choice.

View file

@ -3,15 +3,12 @@ from __future__ import annotations
import os
import re
from functools import cache
from typing import TYPE_CHECKING, Literal
from typing import Literal
import confuse
from beets import config
if TYPE_CHECKING:
from beets.autotag.distance import Distance
# ANSI terminal colorization code heavily inspired by pygments:
# https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py
# (pygments is by Tim Hatch, Armin Ronacher, et al.)
@ -159,19 +156,6 @@ def colorize(color_name: ColorName, text: str) -> str:
return text
def dist_colorize(string: str, dist: Distance) -> str:
"""Formats a string as a colorized similarity string according to
a distance.
"""
if dist <= config["match"]["strong_rec_thresh"].as_number():
string = colorize("text_success", string)
elif dist <= config["match"]["medium_rec_thresh"].as_number():
string = colorize("text_warning", string)
else:
string = colorize("text_error", string)
return string
def uncolorize(colored_text: str) -> str:
"""Remove colors from a string."""
# Define a regular expression to match ANSI codes.