Skip to content

Commit e3cb29e

Browse files
committed
Fix bug with eval of regular vs. ~ tilde slugs
1 parent 7cdbb3a commit e3cb29e

File tree

3 files changed

+136
-1
lines changed

3 files changed

+136
-1
lines changed

picard/ui/itemviews/custom_columns/providers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from picard import log
3030
from picard.item import Item
31+
from picard.script.parser import normalize_tagname
3132

3233
from picard.ui.itemviews.custom_columns.protocols import ColumnValueProvider
3334

@@ -44,6 +45,8 @@ def evaluate(self, obj: Item) -> str:
4445
m = re.fullmatch(r"%(.+)%", lookup_key)
4546
if m:
4647
lookup_key = m.group(1)
48+
# Normalize leading underscore variables to hidden tag prefix '~'
49+
lookup_key = normalize_tagname(lookup_key)
4750
except (AttributeError, KeyError, TypeError) as e:
4851
log.debug("%s failure for key %r: %r", self.__class__.__name__, self.key, e)
4952
return ""

picard/ui/itemviews/custom_columns/resolve.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
ScriptError,
4040
ScriptParser,
4141
)
42+
from picard.script.parser import normalize_tagname
4243

4344

4445
class ValueResolver(ABC):
@@ -157,7 +158,9 @@ def resolve(self, obj: Item, simple_var: str | None, script: str, ctx: Any, file
157158
"""Return the string value from ``obj.column(simple_var)`` or ``""``."""
158159
column_fn = getattr(obj, 'column', None)
159160
if callable(column_fn):
160-
value = column_fn(simple_var)
161+
# Map leading underscore variables to '~' for hidden tags (e.g. _bitrate -> ~bitrate)
162+
normalized_var = normalize_tagname(simple_var) if simple_var is not None else simple_var
163+
value = column_fn(normalized_var)
161164
return value if isinstance(value, str) else ""
162165
return ""
163166

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Picard, the next-generation MusicBrainz tagger
4+
#
5+
# Copyright (C) 2025 The MusicBrainz Team
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License
9+
# as published by the Free Software Foundation; either version 2
10+
# of the License, or (at your option) any later version.
11+
#
12+
# This program is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU General Public License
18+
# along with this program; if not, write to the Free Software
19+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20+
21+
from __future__ import annotations
22+
23+
from dataclasses import dataclass
24+
from types import SimpleNamespace
25+
26+
from picard.item import Item
27+
from picard.metadata import Metadata
28+
29+
import pytest # type: ignore[import-not-found]
30+
31+
from picard.ui.itemviews.custom_columns import make_field_column, make_script_column
32+
33+
34+
@pytest.fixture(autouse=True)
35+
def _fake_script_config(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
36+
"""Provide minimal config so script parser can load functions without KeyError."""
37+
38+
class _FakeSetting(dict):
39+
def raw_value(self, name, qtype=None):
40+
return self.get(name)
41+
42+
def key(self, name):
43+
return name
44+
45+
cfg = SimpleNamespace(setting=_FakeSetting({'enabled_plugins': []}), sync=lambda: None)
46+
import picard.config as picard_config_mod
47+
import picard.extension_points as ext_points_mod
48+
49+
monkeypatch.setattr(picard_config_mod, 'get_config', lambda: cfg, raising=True)
50+
monkeypatch.setattr(ext_points_mod, 'get_config', lambda: cfg, raising=True)
51+
return cfg
52+
53+
54+
@dataclass(eq=False)
55+
class _Item(Item):
56+
values: dict[str, str]
57+
58+
def column(self, key: str) -> str:
59+
return self.values.get(key, "")
60+
61+
@property
62+
def metadata(self) -> Metadata:
63+
md = Metadata()
64+
for k, v in self.values.items():
65+
md[k] = v
66+
return md
67+
68+
69+
@pytest.mark.parametrize(
70+
("tag", "value"),
71+
[
72+
("bitrate", "192 kbps"),
73+
("filesize", "1.5 MB"),
74+
("length", "3:45"),
75+
("format", "MP3"),
76+
("albumartists_countries", "US; GB"),
77+
("artists_sort", "Lastname, Firstname"),
78+
("bits_per_sample", "24"),
79+
],
80+
)
81+
@pytest.mark.parametrize("expr", ["%_bitrate%"], ids=["underscore"]) # expr pattern will be rebuilt per tag
82+
def test_script_column_normalizes_hidden_tag(tag: str, value: str, expr: str) -> None:
83+
# Build expression for current tag
84+
expr_for_tag: str = expr.replace("bitrate", tag)
85+
item: _Item = _Item(values={f"~{tag}": value})
86+
col = make_script_column("HiddenTag", "hidden_script", expr_for_tag)
87+
assert col.provider.evaluate(item) == value
88+
89+
90+
@pytest.mark.parametrize(
91+
("tag", "value"),
92+
[
93+
("bitrate", "192 kbps"),
94+
("filesize", "1.5 MB"),
95+
("length", "3:45"),
96+
("format", "MP3"),
97+
("albumartists_countries", "US; GB"),
98+
("artists_sort", "Lastname, Firstname"),
99+
("bits_per_sample", "24"),
100+
],
101+
)
102+
@pytest.mark.parametrize(
103+
"field_key", ["%_bitrate%", "_bitrate", "~bitrate"], ids=["percent_underscore", "underscore", "tilde"]
104+
) # key pattern rebuilt per tag
105+
def test_field_column_normalizes_hidden_tag(tag: str, value: str, field_key: str) -> None:
106+
key_for_tag: str = field_key.replace("bitrate", tag)
107+
item: _Item = _Item(values={f"~{tag}": value})
108+
col = make_field_column("HiddenTag", key_for_tag)
109+
assert col.provider.evaluate(item) == value
110+
111+
112+
@pytest.mark.parametrize(("tag", "value"), [("artist", "Artist X"), ("title", "Title Y"), ("album", "Album Z")])
113+
@pytest.mark.parametrize(
114+
"expr", ["%artist%", "%title%", "%album%"], ids=["artist", "title", "album"]
115+
) # rebuilt per tag
116+
def test_script_column_regular_tags_still_work(tag: str, value: str, expr: str) -> None:
117+
expr_for_tag: str = expr.replace("artist", tag).replace("title", tag).replace("album", tag)
118+
item: _Item = _Item(values={tag: value})
119+
col = make_script_column("RegularTag", "regular_script", expr_for_tag)
120+
assert col.provider.evaluate(item) == value
121+
122+
123+
@pytest.mark.parametrize(("tag", "value"), [("artist", "Artist X"), ("title", "Title Y"), ("album", "Album Z")])
124+
@pytest.mark.parametrize("field_key", ["artist", "%artist%"], ids=["plain", "percent"]) # rebuilt per tag
125+
def test_field_column_regular_tags_still_work(tag: str, value: str, field_key: str) -> None:
126+
key_for_tag: str = field_key.replace("artist", tag)
127+
item: _Item = _Item(values={tag: value})
128+
col = make_field_column("RegularTag", key_for_tag)
129+
assert col.provider.evaluate(item) == value

0 commit comments

Comments
 (0)