Move diff utils to beets/util/diff.py

This commit is contained in:
Šarūnas Nejus 2026-03-14 10:48:01 +00:00
parent a6fcb7ba0f
commit 756d5c9921
No known key found for this signature in database
4 changed files with 83 additions and 76 deletions

View file

@ -28,7 +28,6 @@ import sqlite3
import sys
import textwrap
import traceback
from difflib import SequenceMatcher
from functools import cache
from typing import TYPE_CHECKING, Any
@ -47,13 +46,12 @@ from beets.util.color import (
uncolorize,
)
from beets.util.deprecation import deprecate_for_maintainers
from beets.util.diff import _field_diff
from beets.util.functemplate import template
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from beets.dbcore.db import FormattedMapping
# On Windows platforms, use colorama to support "ANSI" terminal colors.
if sys.platform == "win32":
@ -444,25 +442,6 @@ def input_select_objects(prompt, objs, rep, prompt_all=None):
return []
def colordiff(a: str, b: str) -> tuple[str, str]:
"""Intelligently highlight the differences between two strings."""
before = ""
after = ""
matcher = SequenceMatcher(lambda _: False, a, b)
for op, a_start, a_end, b_start, b_end in matcher.get_opcodes():
before_part, after_part = a[a_start:a_end], b[b_start:b_end]
if op in {"delete", "replace"}:
before_part = colorize("text_diff_removed", before_part)
if op in {"insert", "replace"}:
after_part = colorize("text_diff_added", after_part)
before += before_part
after += after_part
return before, after
def get_path_formats(subview=None):
"""Get the configuration's path formats as a list of query/template
pairs.
@ -816,59 +795,6 @@ def print_newline_layout(
print_(f"{indent_str * 2}{line}")
FLOAT_EPSILON = 0.01
def _multi_value_diff(field: str, oldset: set[str], newset: set[str]) -> str:
added = newset - oldset
removed = oldset - newset
parts = [
f"{field}:",
*(colorize("text_diff_removed", f" - {i}") for i in sorted(removed)),
*(colorize("text_diff_added", f" + {i}") for i in sorted(added)),
]
return "\n".join(parts)
def _field_diff(
field: str, old: FormattedMapping, new: FormattedMapping
) -> str | None:
"""Given two Model objects and their formatted views, format their values
for `field` and highlight changes among them. Return a human-readable
string. If the value has not changed, return None instead.
"""
# If no change, abort.
if (oldval := old.model.get(field)) == (newval := new.model.get(field)) or (
isinstance(oldval, float)
and isinstance(newval, float)
and abs(oldval - newval) < FLOAT_EPSILON
):
return None
if isinstance(oldval, list):
if (oldset := set(oldval)) != (newset := set(newval)):
return _multi_value_diff(field, oldset, newset)
return None
# Get formatted values for output.
oldstr, newstr = old.get(field, ""), new.get(field, "")
if field not in new:
return colorize("text_diff_removed", f"{field}: {oldstr}")
if field not in old:
return colorize("text_diff_added", f"{field}: {newstr}")
# For strings, highlight changes. For others, colorize the whole thing.
if isinstance(oldval, str):
oldstr, newstr = colordiff(oldstr, newstr)
else:
oldstr = colorize("text_diff_removed", oldstr)
newstr = colorize("text_diff_added", newstr)
return f"{field}: {oldstr} -> {newstr}"
def show_model_changes(
new: library.LibModel,
old: library.LibModel | None = None,

81
beets/util/diff.py Normal file
View file

@ -0,0 +1,81 @@
from __future__ import annotations
from difflib import SequenceMatcher
from typing import TYPE_CHECKING
from .color import colorize
if TYPE_CHECKING:
from beets.dbcore.db import FormattedMapping
def colordiff(a: str, b: str) -> tuple[str, str]:
"""Intelligently highlight the differences between two strings."""
before = ""
after = ""
matcher = SequenceMatcher(lambda _: False, a, b)
for op, a_start, a_end, b_start, b_end in matcher.get_opcodes():
before_part, after_part = a[a_start:a_end], b[b_start:b_end]
if op in {"delete", "replace"}:
before_part = colorize("text_diff_removed", before_part)
if op in {"insert", "replace"}:
after_part = colorize("text_diff_added", after_part)
before += before_part
after += after_part
return before, after
FLOAT_EPSILON = 0.01
def _multi_value_diff(field: str, oldset: set[str], newset: set[str]) -> str:
added = newset - oldset
removed = oldset - newset
parts = [
f"{field}:",
*(colorize("text_diff_removed", f" - {i}") for i in sorted(removed)),
*(colorize("text_diff_added", f" + {i}") for i in sorted(added)),
]
return "\n".join(parts)
def _field_diff(
field: str, old: FormattedMapping, new: FormattedMapping
) -> str | None:
"""Given two Model objects and their formatted views, format their values
for `field` and highlight changes among them. Return a human-readable
string. If the value has not changed, return None instead.
"""
# If no change, abort.
if (oldval := old.model.get(field)) == (newval := new.model.get(field)) or (
isinstance(oldval, float)
and isinstance(newval, float)
and abs(oldval - newval) < FLOAT_EPSILON
):
return None
if isinstance(oldval, list):
if (oldset := set(oldval)) != (newset := set(newval)):
return _multi_value_diff(field, oldset, newset)
return None
# Get formatted values for output.
oldstr, newstr = old.get(field, ""), new.get(field, "")
if field not in new:
return colorize("text_diff_removed", f"{field}: {oldstr}")
if field not in old:
return colorize("text_diff_added", f"{field}: {newstr}")
# For strings, highlight changes. For others, colorize the whole thing.
if isinstance(oldval, str):
oldstr, newstr = colordiff(oldstr, newstr)
else:
oldstr = colorize("text_diff_removed", oldstr)
newstr = colorize("text_diff_added", newstr)
return f"{field}: {oldstr} -> {newstr}"

View file

@ -329,7 +329,7 @@ ignore = [
"beets/**" = ["PT"]
"test/plugins/test_ftintitle.py" = ["E501"]
"test/test_util.py" = ["E501"]
"test/ui/test_field_diff.py" = ["E501"]
"test/util/test_diff.py" = ["E501"]
"test/util/test_id_extractors.py" = ["E501"]
"test/**" = ["RUF001"] # we use Unicode characters in tests