From aced802c5644722c8ca87f3419f463541d22a0a8 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Thu, 20 Nov 2025 15:57:22 -0500 Subject: [PATCH 1/7] Fix recursion in inline plugin when item_fields shadow DB fields (#6115) --- beetsplug/inline.py | 13 +++++++++---- test/plugins/test_plugins.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 test/plugins/test_plugins.py 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/test/plugins/test_plugins.py b/test/plugins/test_plugins.py new file mode 100644 index 000000000..a606f16ca --- /dev/null +++ b/test/plugins/test_plugins.py @@ -0,0 +1,31 @@ +# test/plugins/test_plugins.py + +from beets import config, plugins +from beets.test.helper import PluginTestCase + +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' \ No newline at end of file From ba45fedde581dfe7a0c848bcc1f3c0ef3b5a826d Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Thu, 20 Nov 2025 16:09:01 -0500 Subject: [PATCH 2/7] Fix inline recursion test formatting --- test/plugins/test_plugins.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/plugins/test_plugins.py b/test/plugins/test_plugins.py index a606f16ca..f4baf3663 100644 --- a/test/plugins/test_plugins.py +++ b/test/plugins/test_plugins.py @@ -3,14 +3,14 @@ from beets import config, plugins from beets.test.helper import PluginTestCase + class TestInlineRecursion(PluginTestCase): def test_no_recursion_when_inline_shadows_fixed_field(self): - config['plugins'] = ['inline'] + config["plugins"] = ["inline"] - config['item_fields'] = { - 'track_no': ( - "f'{disc:02d}-{track:02d}' if disctotal > 1 " - "else f'{track:02d}'" + config["item_fields"] = { + "track_no": ( + "f'{disc:02d}-{track:02d}' if disctotal > 1 else f'{track:02d}'" ) } @@ -18,14 +18,14 @@ class TestInlineRecursion(PluginTestCase): plugins.load_plugins() item = self.add_item_fixture( - artist='Artist', - album='Album', - title='Title', + artist="Artist", + album="Album", + title="Title", track=1, disc=1, disctotal=1, ) - out = item.evaluate_template('$track_no') + out = item.evaluate_template("$track_no") - assert out == '01' \ No newline at end of file + assert out == "01" From 13f95dcf3a43ba9bffab4ecf210de2afa6d232da Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 18:12:36 -0500 Subject: [PATCH 3/7] Added documentation header --- test/plugins/{test_plugins.py => test_inline.py} | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) rename test/plugins/{test_plugins.py => test_inline.py} (53%) diff --git a/test/plugins/test_plugins.py b/test/plugins/test_inline.py similarity index 53% rename from test/plugins/test_plugins.py rename to test/plugins/test_inline.py index f4baf3663..fb6c038d0 100644 --- a/test/plugins/test_plugins.py +++ b/test/plugins/test_inline.py @@ -1,4 +1,16 @@ -# test/plugins/test_plugins.py +# 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 e827d43213d99a6938812ea90d19ca2ea337ebac Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 18:34:24 -0500 Subject: [PATCH 4/7] Fixed unit tests --- test/plugins/test_inline.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_inline.py b/test/plugins/test_inline.py index fb6c038d0..5f4ded8f6 100644 --- a/test/plugins/test_inline.py +++ b/test/plugins/test_inline.py @@ -14,7 +14,7 @@ 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): @@ -41,3 +41,19 @@ class TestInlineRecursion(PluginTestCase): 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())) From c59134bdb6f3ee0fa53da1308d4a23fd1900a1b3 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 18:38:09 -0500 Subject: [PATCH 5/7] Fixed unit tests import --- test/plugins/test_inline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/plugins/test_inline.py b/test/plugins/test_inline.py index 5f4ded8f6..7a6b5c360 100644 --- a/test/plugins/test_inline.py +++ b/test/plugins/test_inline.py @@ -16,6 +16,7 @@ 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"] From 51164024c02a6cf423193b63404eb3910858bea3 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 18:41:31 -0500 Subject: [PATCH 6/7] Fixed unit tests import --- test/plugins/test_inline.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_inline.py b/test/plugins/test_inline.py index 7a6b5c360..79118bd06 100644 --- a/test/plugins/test_inline.py +++ b/test/plugins/test_inline.py @@ -54,7 +54,9 @@ class TestInlineRecursion(PluginTestCase): def test_inline_album_expression_uses_items(self): plugin = InlinePlugin() - func = plugin.compile_inline("len(items)", album=True, field_name="item_count") + func = plugin.compile_inline( + "len(items)", album=True, field_name="item_count" + ) album = self.add_album_fixture() assert func(album) == len(list(album.items())) From cd8e466a46abeabb5b0bc491b69ad397c9b58bd4 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 19:18:10 -0500 Subject: [PATCH 7/7] Updated changelog documentation --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) 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`