From 756d5c9921f75b60def72c5dcd50dadc3a8f9460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 14 Mar 2026 10:48:01 +0000 Subject: [PATCH] Move diff utils to beets/util/diff.py --- beets/ui/__init__.py | 76 +---------------- beets/util/diff.py | 81 +++++++++++++++++++ pyproject.toml | 2 +- .../test_field_diff.py => util/test_diff.py} | 0 4 files changed, 83 insertions(+), 76 deletions(-) create mode 100644 beets/util/diff.py rename test/{ui/test_field_diff.py => util/test_diff.py} (100%) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index fe14e511a..930b918f5 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -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, diff --git a/beets/util/diff.py b/beets/util/diff.py new file mode 100644 index 000000000..c01a35a6e --- /dev/null +++ b/beets/util/diff.py @@ -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}" diff --git a/pyproject.toml b/pyproject.toml index 43775351a..89abb6c7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/test/ui/test_field_diff.py b/test/util/test_diff.py similarity index 100% rename from test/ui/test_field_diff.py rename to test/util/test_diff.py