Refactor template evaluation to handle format strings ONLY

- Handle formats as strings ONLY and evaluate them through a shared
  cached template helper.
- This removes the need for conditional logic that acts on Template
  objects or strings.
This commit is contained in:
Šarūnas Nejus
2026-06-13 18:42:08 +01:00
parent c90f42dbdb
commit 3e9ea8ebe8
10 changed files with 35 additions and 61 deletions

View File

@@ -34,8 +34,9 @@ from typing_extensions import (
from unidecode import unidecode
import beets
from beets.util.functemplate import get_template
from ..util import cached_classproperty, functemplate
from ..util import cached_classproperty
from . import types
from .query import MatchQuery, TrueQuery
from .sort import NullSort
@@ -676,20 +677,13 @@ class Model(ABC, Generic[D]):
"""
return self._formatter(self, included_keys, for_path)
def evaluate_template(
self, template: str | functemplate.Template, for_path: bool = False
) -> str:
"""Evaluate a template (a string or a `Template` object) using
the object's fields. If `for_path` is true, then no new path
separators will be added to the template.
def evaluate_template(self, fmt: str, for_path: bool = False) -> str:
"""Evaluate a format string using the object's fields.
If `for_path` is true, then no new path separators are added to the template.
"""
# Perform substitution.
if isinstance(template, str):
t = functemplate.template(template)
else:
# Help out mypy
t = template
return t.substitute(
return get_template(fmt).substitute(
self.formatted(for_path=for_path), self._template_funcs()
)

View File

@@ -27,7 +27,6 @@ from beets.util import (
syspath,
)
from beets.util.deprecation import maybe_replace_legacy_field
from beets.util.functemplate import Template, template
from beets.util.pathformats import PF_KEY_DEFAULT
from .exceptions import FileOperationError, ReadError, WriteError
@@ -39,6 +38,7 @@ if TYPE_CHECKING:
from beets.dbcore.query import FieldQuery, FieldQueryType
from beets.dbcore.sort import FieldSort
from beets.util.pathformats import PathFormat
from .library import Library # noqa: F401
@@ -101,10 +101,9 @@ class LibModel(dbcore.Model["Library"]):
super().add(lib)
def __format__(self, spec):
if not spec:
spec = beets.config[self._format_config_key].as_str()
assert isinstance(spec, str)
return self.evaluate_template(spec)
return self.evaluate_template(
spec or beets.config[self._format_config_key].as_str()
)
def __str__(self):
return format(self)
@@ -526,8 +525,8 @@ class Album(LibModel):
image = bytestring_path(image)
item_dir = item_dir or self.item_dir()
filename_tmpl = template(beets.config["art_filename"].as_str())
subpath = self.evaluate_template(filename_tmpl, True)
filename_tmpl = beets.config["art_filename"].as_str()
subpath = self.evaluate_template(filename_tmpl, for_path=True)
if beets.config["asciify_paths"]:
subpath = util.asciify_path(
subpath, beets.config["path_sep_replace"].as_str()
@@ -1182,7 +1181,10 @@ class Item(LibModel):
# Templating.
def destination(
self, relative_to_libdir=False, basedir=None, path_formats=None
self,
relative_to_libdir=False,
basedir=None,
path_formats: list[PathFormat] | None = None,
) -> bytes:
"""Return the path in the library directory designated for the item
(i.e., where the file ought to be).
@@ -1212,13 +1214,8 @@ class Item(LibModel):
break
else:
assert False, "no default path format"
if isinstance(path_format, Template):
subpath_tmpl = path_format
else:
subpath_tmpl = template(path_format)
# Evaluate the selected template.
subpath = self.evaluate_template(subpath_tmpl, True)
subpath = self.evaluate_template(path_format, for_path=True)
# Prepare path for output: normalize Unicode characters.
if sys.platform == "darwin":

View File

@@ -2,7 +2,6 @@
from beets import library, ui
from beets.exceptions import UserError
from beets.util import functemplate
from beets.util.deprecation import maybe_replace_legacy_field
from .utils import do_query
@@ -26,13 +25,10 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit):
# objects.
ui.print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.")
changed = []
templates = {
key: functemplate.template(value) for key, value in mods.items()
}
for obj in objs:
obj_mods = {
key: model_cls._parse(key, obj.evaluate_template(templates[key]))
for key in mods.keys()
key: model_cls._parse(key, obj.evaluate_template(fmt))
for key, fmt in mods.items()
}
if print_and_modify(obj, obj_mods, dels) and obj not in changed:
changed.append(obj)

View File

@@ -12,11 +12,13 @@ This is sort of like a tiny, horrible degeneration of a real templating
engine like Jinja2 or Mustache.
"""
from __future__ import annotations
import ast
import dis
import functools
import re
import types
from functools import lru_cache
SYMBOL_DELIM = "$"
FUNC_DELIM = "%"
@@ -494,8 +496,8 @@ def _parse(template):
return Expression(parts)
@functools.lru_cache(maxsize=128)
def template(fmt):
@lru_cache(maxsize=128)
def get_template(fmt: str) -> Template:
return Template(fmt)

View File

@@ -2,14 +2,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from .functemplate import template
if TYPE_CHECKING:
import confuse
from .functemplate import Template
PathFormat = tuple[str, Template]
PathFormat = tuple[str, str]
# Special path format key.
@@ -25,7 +21,4 @@ def get_path_formats(subview: confuse.Subview) -> list[PathFormat]:
part of ``paths``. This keeps inherited defaults such as ``default``,
``comp``, and ``singleton`` available unless they are explicitly replaced.
"""
return [
(PF_KEY_QUERIES.get(q, q), template(v.as_str()))
for q, v in subview.items()
]
return [(PF_KEY_QUERIES.get(q, q), v.as_str()) for q, v in subview.items()]

View File

@@ -6,7 +6,6 @@ import timeit
from beets import importer, plugins, ui
from beets.autotag import tag_album
from beets.plugins import BeetsPlugin
from beets.util.functemplate import Template
from beets.util.pathformats import PF_KEY_DEFAULT
from beetsplug._utils import vfs
@@ -17,10 +16,7 @@ def aunique_benchmark(lib, prof):
# Measure path generation performance with %aunique{} included.
lib.path_formats = [
(
PF_KEY_DEFAULT,
Template("$albumartist/$album%aunique{}/$track $title"),
)
(PF_KEY_DEFAULT, "$albumartist/$album%aunique{}/$track $title")
]
if prof:
cProfile.runctx(
@@ -35,7 +31,7 @@ def aunique_benchmark(lib, prof):
# And with %aunique replaced with a "cheap" no-op function.
lib.path_formats = [
(PF_KEY_DEFAULT, Template("$albumartist/$album%lower{}/$track $title"))
(PF_KEY_DEFAULT, "$albumartist/$album%lower{}/$track $title")
]
if prof:
cProfile.runctx(

View File

@@ -389,7 +389,7 @@ class SmartPlaylistPlugin(plugins.BeetsPlugin):
# the items and generate the correct m3u file names.
matched_items: list[Item] = []
for item in items:
m3u_name = item.evaluate_template(name, True)
m3u_name = item.evaluate_template(name, for_path=True)
m3u_name = sanitize_path(m3u_name, lib.replacements)
item_uri = self.get_item_uri(item)

View File

@@ -160,7 +160,7 @@ class SmartPlaylistTest(BeetsTestCase):
spl = SmartPlaylistPlugin()
i = Mock(path=b"/tagada.mp3")
i.evaluate_template.side_effect = lambda pl, *_: os.fsdecode(
i.evaluate_template.side_effect = lambda pl, **__: os.fsdecode(
pl
).replace("$title", "ta:ga:da")
@@ -201,7 +201,7 @@ class SmartPlaylistTest(BeetsTestCase):
type(i).title = PropertyMock(return_value="fake title")
type(i).length = PropertyMock(return_value=300.123)
type(i).path = PropertyMock(return_value=b"/tagada.mp3")
i.evaluate_template.side_effect = lambda pl, *_: os.fsdecode(
i.evaluate_template.side_effect = lambda pl, **__: os.fsdecode(
pl
).replace("$title", "ta:ga:da")
@@ -250,7 +250,7 @@ class SmartPlaylistTest(BeetsTestCase):
type(i).path = PropertyMock(return_value=b"/tagada.mp3")
a = {"id": 456, "genres": ["Rock", "Pop"]}
i.__getitem__.side_effect = a.__getitem__
i.evaluate_template.side_effect = lambda pl, *_: os.fsdecode(
i.evaluate_template.side_effect = lambda pl, **__: os.fsdecode(
pl
).replace("$title", "ta:ga:da")

View File

@@ -148,9 +148,7 @@ class ConfigTest(IOMixin, TestPluginTestCase):
config.write("paths: {x: y}")
self.run_command("test")
key, template = self.test_cmd.lib.path_formats[0]
assert key == "x"
assert template.original == "y"
assert self.test_cmd.lib.path_formats[0] == ("x", "y")
def test_nonexistant_db(self):
with self.write_config_file() as config:

View File

@@ -6,9 +6,7 @@ def test_get_path_formats(config):
# override the default 'singleton' path and add a new one
config["paths"].set({"singleton": "bar", "new": "hello"})
path_formats = get_path_formats(config["paths"])
actual_path_formats = [(key, tmpl.original) for key, tmpl in path_formats]
assert actual_path_formats == [
assert get_path_formats(config["paths"]) == [
("singleton:true", "bar"),
("new", "hello"),
# defaults