Skip to content

Commit b4f0dbf

Browse files
Fix recursion in inline plugin when item_fields shadow DB fields (#6115) (#6174)
## Description Fixes [#6115](#6115). When an inline field definition shadows a built-in database field (e.g., redefining `track_no` in `item_fields`), the inline plugin evaluates the field template by constructing a dictionary of all item values. Previously, this triggered unbounded recursion because `_dict_for(obj)` re-entered `__getitem__` for the same key while evaluating the computed field. This PR adds a per-object, per-key evaluation guard to prevent re-entry when the same inline field is accessed during expression evaluation. This resolves the recursion error while preserving normal computed-field behavior. A regression test (`TestInlineRecursion.test_no_recursion_when_inline_shadows_fixed_field`) verifies that `$track_no` evaluates correctly (`'01'`) when shadowed. ## To Do - [x] ~Documentation.~ - [x] ~Changelog.~ - [x] Tests.
2 parents b902352 + cd8e466 commit b4f0dbf

File tree

3 files changed

+75
-4
lines changed

3 files changed

+75
-4
lines changed

beetsplug/inline.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,18 @@ def __init__(self):
6161
config["item_fields"].items(), config["pathfields"].items()
6262
):
6363
self._log.debug("adding item field {}", key)
64-
func = self.compile_inline(view.as_str(), False)
64+
func = self.compile_inline(view.as_str(), False, key)
6565
if func is not None:
6666
self.template_fields[key] = func
6767

6868
# Album fields.
6969
for key, view in config["album_fields"].items():
7070
self._log.debug("adding album field {}", key)
71-
func = self.compile_inline(view.as_str(), True)
71+
func = self.compile_inline(view.as_str(), True, key)
7272
if func is not None:
7373
self.album_template_fields[key] = func
7474

75-
def compile_inline(self, python_code, album):
75+
def compile_inline(self, python_code, album, field_name):
7676
"""Given a Python expression or function body, compile it as a path
7777
field function. The returned function takes a single argument, an
7878
Item, and returns a Unicode string. If the expression cannot be
@@ -97,7 +97,12 @@ def compile_inline(self, python_code, album):
9797
is_expr = True
9898

9999
def _dict_for(obj):
100-
out = dict(obj)
100+
out = {}
101+
for key in obj.keys(computed=False):
102+
if key == field_name:
103+
continue
104+
out[key] = obj._get(key)
105+
101106
if album:
102107
out["items"] = list(obj.items())
103108
return out

docs/changelog.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ New features:
3232

3333
Bug fixes:
3434

35+
- :doc:`plugins/inline`: Fix recursion error when an inline field definition
36+
shadows a built-in item field (e.g., redefining ``track_no``). Inline
37+
expressions now skip self-references during evaluation to avoid infinite
38+
recursion. :bug:`6115`
3539
- When hardlinking from a symlink (e.g. importing a symlink with hardlinking
3640
enabled), dereference the symlink then hardlink, rather than creating a new
3741
(potentially broken) symlink :bug:`5676`

test/plugins/test_inline.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# This file is part of beets.
2+
# Copyright 2025, Gabe Push.
3+
#
4+
# Permission is hereby granted, free of charge, to any person obtaining
5+
# a copy of this software and associated documentation files (the
6+
# "Software"), to deal in the Software without restriction, including
7+
# without limitation the rights to use, copy, modify, merge, publish,
8+
# distribute, sublicense, and/or sell copies of the Software, and to
9+
# permit persons to whom the Software is furnished to do so, subject to
10+
# the following conditions:
11+
#
12+
# The above copyright notice and this permission notice shall be
13+
# included in all copies or substantial portions of the Software.
14+
15+
from beets import config, plugins
16+
from beets.test.helper import PluginTestCase
17+
from beetsplug.inline import InlinePlugin
18+
19+
20+
class TestInlineRecursion(PluginTestCase):
21+
def test_no_recursion_when_inline_shadows_fixed_field(self):
22+
config["plugins"] = ["inline"]
23+
24+
config["item_fields"] = {
25+
"track_no": (
26+
"f'{disc:02d}-{track:02d}' if disctotal > 1 else f'{track:02d}'"
27+
)
28+
}
29+
30+
plugins._instances.clear()
31+
plugins.load_plugins()
32+
33+
item = self.add_item_fixture(
34+
artist="Artist",
35+
album="Album",
36+
title="Title",
37+
track=1,
38+
disc=1,
39+
disctotal=1,
40+
)
41+
42+
out = item.evaluate_template("$track_no")
43+
44+
assert out == "01"
45+
46+
def test_inline_function_body_item_field(self):
47+
plugin = InlinePlugin()
48+
func = plugin.compile_inline(
49+
"return track + 1", album=False, field_name="next_track"
50+
)
51+
52+
item = self.add_item_fixture(track=3)
53+
assert func(item) == 4
54+
55+
def test_inline_album_expression_uses_items(self):
56+
plugin = InlinePlugin()
57+
func = plugin.compile_inline(
58+
"len(items)", album=True, field_name="item_count"
59+
)
60+
61+
album = self.add_album_fixture()
62+
assert func(album) == len(list(album.items()))

0 commit comments

Comments
 (0)