diff --git a/beetsplug/inline.py b/beetsplug/inline.py index e9a94ac38..860a205ee 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -61,18 +61,18 @@ class InlinePlugin(BeetsPlugin): config["item_fields"].items(), config["pathfields"].items() ): self._log.debug("adding item field {}", key) - func = self.compile_inline(view.as_str(), False) + func = self.compile_inline(view.as_str(), False, key) if func is not None: self.template_fields[key] = func # Album fields. for key, view in config["album_fields"].items(): self._log.debug("adding album field {}", key) - func = self.compile_inline(view.as_str(), True) + func = self.compile_inline(view.as_str(), True, key) if func is not None: self.album_template_fields[key] = func - def compile_inline(self, python_code, album): + def compile_inline(self, python_code, album, field_name): """Given a Python expression or function body, compile it as a path field function. The returned function takes a single argument, an Item, and returns a Unicode string. If the expression cannot be @@ -97,7 +97,12 @@ class InlinePlugin(BeetsPlugin): is_expr = True def _dict_for(obj): - out = dict(obj) + out = {} + for key in obj.keys(computed=False): + if key == field_name: + continue + out[key] = obj._get(key) + if album: out["items"] = list(obj.items()) return out diff --git a/docs/changelog.rst b/docs/changelog.rst index d1a0e8c7f..19026eafe 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,10 @@ New features: Bug fixes: +- :doc:`plugins/inline`: Fix recursion error when an inline field definition + shadows a built-in item field (e.g., redefining ``track_no``). Inline + expressions now skip self-references during evaluation to avoid infinite + recursion. :bug:`6115` - When hardlinking from a symlink (e.g. importing a symlink with hardlinking enabled), dereference the symlink then hardlink, rather than creating a new (potentially broken) symlink :bug:`5676` diff --git a/test/plugins/test_inline.py b/test/plugins/test_inline.py new file mode 100644 index 000000000..79118bd06 --- /dev/null +++ b/test/plugins/test_inline.py @@ -0,0 +1,62 @@ +# This file is part of beets. +# Copyright 2025, Gabe Push. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +from beets import config, plugins +from beets.test.helper import PluginTestCase +from beetsplug.inline import InlinePlugin + + +class TestInlineRecursion(PluginTestCase): + def test_no_recursion_when_inline_shadows_fixed_field(self): + config["plugins"] = ["inline"] + + config["item_fields"] = { + "track_no": ( + "f'{disc:02d}-{track:02d}' if disctotal > 1 else f'{track:02d}'" + ) + } + + plugins._instances.clear() + plugins.load_plugins() + + item = self.add_item_fixture( + artist="Artist", + album="Album", + title="Title", + track=1, + disc=1, + disctotal=1, + ) + + out = item.evaluate_template("$track_no") + + assert out == "01" + + def test_inline_function_body_item_field(self): + plugin = InlinePlugin() + func = plugin.compile_inline( + "return track + 1", album=False, field_name="next_track" + ) + + item = self.add_item_fixture(track=3) + assert func(item) == 4 + + def test_inline_album_expression_uses_items(self): + plugin = InlinePlugin() + func = plugin.compile_inline( + "len(items)", album=True, field_name="item_count" + ) + + album = self.add_album_fixture() + assert func(album) == len(list(album.items()))