From 10f49d12f06f1e76c20e74c8309c959df2f4ade8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 13 Jun 2026 18:42:08 +0100 Subject: [PATCH] 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. --- beets/dbcore/db.py | 20 +++++++------------- beets/library/models.py | 25 +++++++++++-------------- beets/ui/commands/modify.py | 8 ++------ beets/util/functemplate.py | 8 +++++--- beets/util/pathformats.py | 11 ++--------- beetsplug/bench.py | 8 ++------ beetsplug/smartplaylist.py | 2 +- test/plugins/test_smartplaylist.py | 6 +++--- test/ui/test_ui.py | 4 +--- test/util/test_pathformats.py | 4 +--- 10 files changed, 35 insertions(+), 61 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 5d1b136932..30ddea41f4 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -48,8 +48,9 @@ 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 @@ -744,20 +745,13 @@ def formatted( """ 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() ) diff --git a/beets/library/models.py b/beets/library/models.py index aba4db1ae0..86282cf797 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -27,7 +27,6 @@ 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 @@ 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 @@ -99,10 +99,9 @@ def add(self, lib=None): 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) @@ -524,8 +523,8 @@ def art_destination(self, image, item_dir=None): 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() @@ -1178,7 +1177,10 @@ def move( # 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). @@ -1208,13 +1210,8 @@ def destination( 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": diff --git a/beets/ui/commands/modify.py b/beets/ui/commands/modify.py index 0dd54d550c..381de1d637 100644 --- a/beets/ui/commands/modify.py +++ b/beets/ui/commands/modify.py @@ -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) diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index 738caaac7e..8783025e8c 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -26,11 +26,13 @@ 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 = "%" @@ -508,8 +510,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) diff --git a/beets/util/pathformats.py b/beets/util/pathformats.py index 275f0f2cb0..7d1a6faf5d 100644 --- a/beets/util/pathformats.py +++ b/beets/util/pathformats.py @@ -2,14 +2,10 @@ 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()] diff --git a/beetsplug/bench.py b/beetsplug/bench.py index 9df32af436..c71419baa1 100644 --- a/beetsplug/bench.py +++ b/beetsplug/bench.py @@ -20,7 +20,6 @@ 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 @@ -31,10 +30,7 @@ def _build_tree(): # 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( @@ -49,7 +45,7 @@ def _build_tree(): # 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( diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index a11494bdbe..e09203ac17 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -403,7 +403,7 @@ def update_playlists(self, lib: Library) -> None: # 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) diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index e1d21d7b90..b6a26f275c 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -174,7 +174,7 @@ def test_playlist_update(self): 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") @@ -215,7 +215,7 @@ def test_playlist_update_output_extm3u(self): 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") @@ -264,7 +264,7 @@ def test_playlist_update_output_extm3u_fields(self): 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") diff --git a/test/ui/test_ui.py b/test/ui/test_ui.py index 2416a2bdfa..b92a7fdc53 100644 --- a/test/ui/test_ui.py +++ b/test/ui/test_ui.py @@ -162,9 +162,7 @@ def test_paths_section_respected(self): 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: diff --git a/test/util/test_pathformats.py b/test/util/test_pathformats.py index 47846c1915..769377a87f 100644 --- a/test/util/test_pathformats.py +++ b/test/util/test_pathformats.py @@ -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