Fix handling multi valued fields (#6387)

While working on #6367 I noticed that users are currently required to
use our internal separator `\␀` in order to edit multi-valued fields,
for example `beet modify artists='a\␀b'`.

Similarly, this separator is used in output, for example, reporting of
field changes:

```
$ beet modify path::aaa artists='a\␀b'
Modifying 8 items.
54898 | 2022 / RAVE SLUTZ: Fallen Shrine & dj Christian NXC - DEEEJAAAY
  artists: Fallen Shrine\␀dj Christian NXC -> a\␀b
```

This PR replaces `\␀` separator with `; ` for input and formats changes
in multi-valued fields clearly:

```
$ beet modify path::aaa artists='a; b'
54898 | 2022 / RAVE SLUTZ: Fallen Shrine & dj Christian NXC - DEEEJAAAY
  artists:
    - Fallen Shrine
    - dj Christian NXC
    + a
    + b
```
<img width="539" height="142" alt="image"
src="https://github.com/user-attachments/assets/72299db1-d0f8-4f8f-9f30-65caaac85d9e"
/>


### Architecture-level changes

- `DelimitedString` now separates concerns between:
  - database serialization via `db_delimiter` (`to_sql`)
- user-facing/template formatting via a fixed `'; '` delimiter
(`format`)
  - parsing that accepts both DB and user-facing separators (`parse`)
- Field diff rendering now has a dedicated path for list fields:
  - `_field_diff` detects list values
- `_multi_value_diff` computes set-based added/removed entries and
renders per-item diff lines
- Coloring responsibilities were streamlined:
  - raw ANSI application moved to `_colorize`
  - `colorize` is now only the feature-flag/environment gate
- `colordiff` is reduced to string diff highlighting logic, with
redundant wrapper logic removed

### High-level impact

- Multi-valued fields behave consistently between DB storage and
CLI/template usage (`'; '` for user input/output, DB delimiter
internally).
- Diff output for list fields is much more readable, showing explicit
`+`/`-` item-level changes instead of generic string diffs.
- Docs and tests were updated to reflect the new multi-value behavior,
including `%first` usage and `beet modify` examples.
This commit is contained in:
Šarūnas Nejus 2026-02-23 01:14:28 +00:00 committed by GitHub
commit 2ea7886c0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 129 additions and 74 deletions

View file

@ -66,6 +66,8 @@ class Type(ABC, Generic[T, N]):
"""The `Query` subclass to be used when querying the field.
"""
# For sequence-like types, keep ``model_type`` unsubscripted as it's used
# for ``isinstance`` checks. Use ``list`` instead of ``list[str]``
model_type: type[T]
"""The Python type that is used to represent the value in the model.
@ -287,26 +289,38 @@ class String(BaseString[str, Any]):
model_type = str
class DelimitedString(BaseString[list[str], list[str]]):
"""A list of Unicode strings, represented in-database by a single string
class DelimitedString(BaseString[list, list]): # type: ignore[type-arg]
r"""A list of Unicode strings, represented in-database by a single string
containing delimiter-separated values.
In template evaluation the list is formatted by joining the values with
a fixed '; ' delimiter regardless of the database delimiter. That is because
the '\' character used for multi-value fields is mishandled on Windows
as it contains a backslash character.
"""
model_type = list[str]
model_type = list
fmt_delimiter = "; "
def __init__(self, delimiter: str):
self.delimiter = delimiter
def __init__(self, db_delimiter: str):
self.db_delimiter = db_delimiter
def format(self, value: list[str]):
return self.delimiter.join(value)
return self.fmt_delimiter.join(value)
def parse(self, string: str):
if not string:
return []
return string.split(self.delimiter)
delimiter = (
self.db_delimiter
if self.db_delimiter in string
else self.fmt_delimiter
)
return string.split(delimiter)
def to_sql(self, model_value: list[str]):
return self.delimiter.join(model_value)
return self.db_delimiter.join(model_value)
class Boolean(Type):
@ -464,7 +478,7 @@ NULL_FLOAT = NullFloat()
STRING = String()
BOOLEAN = Boolean()
DATE = DateType()
SEMICOLON_SPACE_DSV = DelimitedString(delimiter="; ")
SEMICOLON_SPACE_DSV = DelimitedString("; ")
# Will set the proper null char in mediafile
MULTI_VALUE_DSV = DelimitedString(delimiter="\\")
MULTI_VALUE_DSV = DelimitedString("\\")

View file

@ -1546,8 +1546,8 @@ class DefaultTemplateFunctions:
s: the string
count: The number of items included
skip: The number of items skipped
sep: the separator. Usually is '; ' (default) or '/ '
join_str: the string which will join the items, default '; '.
sep: the separator
join_str: the string which will join the items
"""
skip = int(skip)
count = skip + int(count)

View file

@ -571,15 +571,16 @@ def get_color_config() -> dict[ColorName, str]:
}
def colorize(color_name: ColorName, text: str) -> str:
"""Apply ANSI color formatting to text based on configuration settings.
def _colorize(color_name: ColorName, text: str) -> str:
"""Apply ANSI color formatting to text based on configuration settings."""
color_code = get_color_config()[color_name]
return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}"
Returns colored text when color output is enabled and NO_COLOR environment
variable is not set, otherwise returns plain text unchanged.
"""
def colorize(color_name: ColorName, text: str) -> str:
"""Colorize text when color output is enabled."""
if config["ui"]["color"] and "NO_COLOR" not in os.environ:
color_code = get_color_config()[color_name]
return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}"
return _colorize(color_name, text)
return text
@ -643,32 +644,12 @@ def color_len(colored_text):
return len(uncolorize(colored_text))
def _colordiff(a: Any, b: Any) -> tuple[str, str]:
"""Given two values, return the same pair of strings except with
their differences highlighted in the specified color. Strings are
highlighted intelligently to show differences; other values are
stringified and highlighted in their entirety.
"""
# First, convert paths to readable format
if isinstance(a, bytes) or isinstance(b, bytes):
# A path field.
a = util.displayable_path(a)
b = util.displayable_path(b)
if not isinstance(a, str) or not isinstance(b, str):
# Non-strings: use ordinary equality.
if a == b:
return str(a), str(b)
else:
return (
colorize("text_diff_removed", str(a)),
colorize("text_diff_added", str(b)),
)
def colordiff(a: str, b: str) -> tuple[str, str]:
"""Intelligently highlight the differences between two strings."""
before = ""
after = ""
matcher = SequenceMatcher(lambda x: False, a, b)
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"}:
@ -682,16 +663,6 @@ def _colordiff(a: Any, b: Any) -> tuple[str, str]:
return before, after
def colordiff(a, b):
"""Colorize differences between two values if color is enabled.
(Like _colordiff but conditional.)
"""
if config["ui"]["color"]:
return _colordiff(a, b)
else:
return str(a), str(b)
def get_path_formats(subview=None):
"""Get the configuration's path formats as a list of query/template
pairs.
@ -1048,6 +1019,18 @@ def print_newline_layout(
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:
@ -1063,6 +1046,11 @@ def _field_diff(
):
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:
@ -1071,8 +1059,7 @@ def _field_diff(
if field not in old:
return colorize("text_diff_added", f"{field}: {newstr}")
# For strings, highlight changes. For others, colorize the whole
# thing.
# For strings, highlight changes. For others, colorize the whole thing.
if isinstance(oldval, str):
oldstr, newstr = colordiff(oldstr, newstr)
else:

View file

@ -127,7 +127,7 @@ class ChangeRepresentation:
and artist name.
"""
# Artist.
artist_l, artist_r = self.cur_artist or "", self.match.info.artist
artist_l, artist_r = self.cur_artist or "", self.match.info.artist or ""
if artist_r == VARIOUS_ARTISTS:
# Hide artists for VA releases.
artist_l, artist_r = "", ""

View file

@ -30,7 +30,12 @@ from beets.util import PromptChoice
# These "safe" types can avoid the format/parse cycle that most fields go
# through: they are safe to edit with native YAML types.
SAFE_TYPES = (types.BaseFloat, types.BaseInteger, types.Boolean)
SAFE_TYPES = (
types.BaseFloat,
types.BaseInteger,
types.Boolean,
types.DelimitedString,
)
class ParseError(Exception):

View file

@ -21,9 +21,17 @@ Unreleased
For plugin developers
~~~~~~~~~~~~~~~~~~~~~
..
Other changes
~~~~~~~~~~~~~
Other changes
~~~~~~~~~~~~~
- :ref:`modify-cmd`: Use the following separator to delimite multiple field
values: |semicolon_space|. For example ``beet modify albumtypes="album; ep"``.
Previously, ``\␀`` was used as a separator. This applies to fields such as
``artists``, ``albumtypes`` etc.
- Improve highlighting of multi-valued fields changes.
- :doc:`plugins/edit`: Editing multi-valued fields now behaves more naturally,
with list values handled directly to make metadata edits smoother and more
predictable.
2.6.2 (February 22, 2026)
-------------------------

View file

@ -98,7 +98,7 @@ man_pages = [
]
# Global substitutions that can be used anywhere in the documentation.
rst_epilog = """
rst_epilog = r"""
.. |Album| replace:: :class:`~beets.library.models.Album`
.. |AlbumInfo| replace:: :class:`beets.autotag.hooks.AlbumInfo`
.. |BeetsPlugin| replace:: :class:`beets.plugins.BeetsPlugin`
@ -108,6 +108,7 @@ rst_epilog = """
.. |Library| replace:: :class:`~beets.library.library.Library`
.. |Model| replace:: :class:`~beets.dbcore.db.Model`
.. |TrackInfo| replace:: :class:`beets.autotag.hooks.TrackInfo`
.. |semicolon_space| replace:: :literal:`; \ `
"""
# -- Options for HTML output -------------------------------------------------

View file

@ -267,6 +267,9 @@ Values can also be *templates*, using the same syntax as :doc:`path formats
artist sort name into the artist field for all your tracks, and ``beet modify
title='$track $title'`` will add track numbers to their title metadata.
To adjust a multi-valued field, such as ``genres``, separate the values with
|semicolon_space|. For example, ``beet modify genres="rock; pop"``.
The ``-a`` option changes to querying album fields instead of track fields and
also enables to operate on albums in addition to the individual tracks. Without
this flag, the command will only change *track-level* data, even if all the

View file

@ -75,11 +75,34 @@ These functions are built in to beets:
- ``%time{date_time,format}``: Return the date and time in any format accepted
by strftime_. For example, to get the year some music was added to your
library, use ``%time{$added,%Y}``.
- ``%first{text}``: Returns the first item, separated by ``;`` (a semicolon
followed by a space). You can use ``%first{text,count,skip}``, where ``count``
is the number of items (default 1) and ``skip`` is number to skip (default 0).
You can also use ``%first{text,count,skip,sep,join}`` where ``sep`` is the
separator, like ``;`` or ``/`` and join is the text to concatenate the items.
- ``%first{text,count,skip,sep,join}``: Extract a subset of items from a
delimited string. Splits ``text`` by ``sep``, skips the first ``skip`` items,
then returns the next ``count`` items joined by ``join``.
This is especially useful for multi-valued fields like ``artists`` or
``genres`` where you may only want the first artist or a limited number of
genres in a path.
Defaults:
..
Comically, you need to follow |semicolon_space| with some punctuation to
make sure it gets rendered correctly as '; ' in the docs.
- **count**: 1,
- **skip**: 0,
- **sep**: |semicolon_space|,
- **join**: |semicolon_space|.
Examples:
::
%first{$genres} → returns the first genre
%first{$genres,2} → returns the first two genres, joined by "; "
%first{$genres,2,1} → skips the first genre, returns the next two
%first{$genres,2,0, , -> } → splits by space, joins with " -> "
- ``%ifdef{field}``, ``%ifdef{field,truetext}`` or
``%ifdef{field,truetext,falsetext}``: Checks if an flexible attribute
``field`` is defined. If it exists, then return ``truetext`` or ``field``

View file

@ -688,14 +688,14 @@ class DestinationFunctionTest(BeetsTestCase, PathFormattingMixin):
self._assert_dest(b"/base/not_played")
def test_first(self):
self.i.genres = "Pop; Rock; Classical Crossover"
self._setf("%first{$genres}")
self._assert_dest(b"/base/Pop")
self.i.albumtypes = ["album", "compilation"]
self._setf("%first{$albumtypes}")
self._assert_dest(b"/base/album")
def test_first_skip(self):
self.i.genres = "Pop; Rock; Classical Crossover"
self._setf("%first{$genres,1,2}")
self._assert_dest(b"/base/Classical Crossover")
self.i.albumtype = "album; ep; compilation"
self._setf("%first{$albumtype,1,2}")
self._assert_dest(b"/base/compilation")
def test_first_different_sep(self):
self._setf("%first{Alice / Bob / Eve,2,0, / , & }")

View file

@ -96,8 +96,7 @@ class TestPluginRegistration(IOMixin, PluginTestCase):
item.add(self.lib)
out = self.run_with_output("ls", "-f", "$multi_value")
delimiter = types.MULTI_VALUE_DSV.delimiter
assert out == f"one{delimiter}two{delimiter}three\n"
assert out == "one; two; three\n"
class PluginImportTestCase(ImportHelper, PluginTestCase):

View file

@ -1,3 +1,5 @@
from textwrap import dedent
import pytest
from beets.library import Item
@ -15,7 +17,7 @@ class TestFieldDiff:
def patch_colorize(self, monkeypatch):
"""Patch to return a deterministic string format instead of ANSI codes."""
monkeypatch.setattr(
"beets.ui.colorize",
"beets.ui._colorize",
lambda color_name, text: f"[{color_name}]{text}[/]",
)
@ -38,6 +40,19 @@ class TestFieldDiff:
p({"mb_trackid": None}, {"mb_trackid": "1234"}, "mb_trackid", "mb_trackid: -> [text_diff_added]1234[/]", id="none_to_value"),
p({}, {"new_flex": "foo"}, "new_flex", "[text_diff_added]new_flex: foo[/]", id="flex_field_added"),
p({"old_flex": "foo"}, {}, "old_flex", "[text_diff_removed]old_flex: foo[/]", id="flex_field_removed"),
p({"albumtypes": ["album", "ep"]}, {"albumtypes": ["ep", "album"]}, "albumtypes", None, id="multi_value_unchanged"),
p(
{"albumtypes": ["ep"]},
{"albumtypes": ["album", "compilation"]},
"albumtypes",
dedent("""
albumtypes:
[text_diff_removed] - ep[/]
[text_diff_added] + album[/]
[text_diff_added] + compilation[/]
""").strip(),
id="multi_value_changed"
),
],
) # fmt: skip
@pytest.mark.parametrize("color", [True], ids=["color_enabled"])