Fix recursion in inline plugin when item_fields shadow DB fields (#6115) (#6174)

## Description

Fixes [#6115](https://github.com/beetbox/beets/issues/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.
This commit is contained in:
henry 2025-11-25 16:25:25 -08:00 committed by GitHub
commit b4f0dbf53b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 75 additions and 4 deletions

View file

@ -61,18 +61,18 @@ class InlinePlugin(BeetsPlugin):
config["item_fields"].items(), config["pathfields"].items() config["item_fields"].items(), config["pathfields"].items()
): ):
self._log.debug("adding item field {}", key) 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: if func is not None:
self.template_fields[key] = func self.template_fields[key] = func
# Album fields. # Album fields.
for key, view in config["album_fields"].items(): for key, view in config["album_fields"].items():
self._log.debug("adding album field {}", key) 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: if func is not None:
self.album_template_fields[key] = func 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 """Given a Python expression or function body, compile it as a path
field function. The returned function takes a single argument, an field function. The returned function takes a single argument, an
Item, and returns a Unicode string. If the expression cannot be Item, and returns a Unicode string. If the expression cannot be
@ -97,7 +97,12 @@ class InlinePlugin(BeetsPlugin):
is_expr = True is_expr = True
def _dict_for(obj): 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: if album:
out["items"] = list(obj.items()) out["items"] = list(obj.items())
return out return out

View file

@ -32,6 +32,10 @@ New features:
Bug fixes: 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 - When hardlinking from a symlink (e.g. importing a symlink with hardlinking
enabled), dereference the symlink then hardlink, rather than creating a new enabled), dereference the symlink then hardlink, rather than creating a new
(potentially broken) symlink :bug:`5676` (potentially broken) symlink :bug:`5676`

View file

@ -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()))