diff --git a/src/smolagents/local_python_executor.py b/src/smolagents/local_python_executor.py index 8e150cd5c..83918f7c7 100644 --- a/src/smolagents/local_python_executor.py +++ b/src/smolagents/local_python_executor.py @@ -22,6 +22,7 @@ import math import re from abc import ABC, abstractmethod +from collections import ChainMap from collections.abc import Callable, Generator, Mapping from concurrent.futures import ThreadPoolExecutor from concurrent.futures import TimeoutError as FuturesTimeoutError @@ -594,12 +595,18 @@ def evaluate_class_def( raise InterpreterError(f"Unsupported AnnAssign target in class body: {type(target).__name__}") elif isinstance(stmt, ast.Assign): value = evaluate_ast(stmt.value, state, static_tools, custom_tools, authorized_imports) + # Class-body scoping: names defined earlier in the body take + # precedence, then enclosing scopes — as in native Python. + # Reuse set_value so class bodies support the same targets as + # regular assignments (Name, Attribute, Tuple unpacking, Subscript); + # writes always land in class_dict, so class attributes can never + # erase a module-level tool — skip the tool-rebinding guard here. + class_state = ChainMap(class_dict, state) for target in stmt.targets: - if isinstance(target, ast.Name): - class_dict[target.id] = value - elif isinstance(target, ast.Attribute): - obj = evaluate_ast(target.value, class_dict, static_tools, custom_tools, authorized_imports) - setattr(obj, target.attr, value) + set_value( + target, value, class_state, static_tools, custom_tools, authorized_imports, + check_tool_rebinding=False, + ) elif isinstance(stmt, ast.Pass): pass elif ( @@ -798,9 +805,10 @@ def set_value( static_tools: dict[str, Callable], custom_tools: dict[str, Callable], authorized_imports: list[str], + check_tool_rebinding: bool = True, ) -> None: if isinstance(target, ast.Name): - if target.id in static_tools: + if check_tool_rebinding and target.id in static_tools: raise InterpreterError(f"Cannot assign to name '{target.id}': doing this would erase the existing tool!") state[target.id] = value elif isinstance(target, ast.Tuple): @@ -812,7 +820,7 @@ def set_value( if len(target.elts) != len(value): raise InterpreterError("Cannot unpack tuple of wrong size") for i, elem in enumerate(target.elts): - set_value(elem, value[i], state, static_tools, custom_tools, authorized_imports) + set_value(elem, value[i], state, static_tools, custom_tools, authorized_imports, check_tool_rebinding) elif isinstance(target, ast.Subscript): obj = evaluate_ast(target.value, state, static_tools, custom_tools, authorized_imports) key = evaluate_ast(target.slice, state, static_tools, custom_tools, authorized_imports) diff --git a/tests/test_local_python_executor.py b/tests/test_local_python_executor.py index aede6cd2d..b36fc480d 100644 --- a/tests/test_local_python_executor.py +++ b/tests/test_local_python_executor.py @@ -247,6 +247,46 @@ class TestClass: assert state["TestClass"].data.attr1 == "value1" assert state["TestClass"].data.attr2 == "value2" + def test_evaluate_class_def_with_assign_tuple_and_subscript_targets(self): + """ + Test evaluate_class_def function when stmt is an instance of ast.Assign with ast.Tuple or ast.Subscript targets. + """ + code = dedent(""" + table = {"value": 2} + class Settings: + lower, upper = (2, 7) + table["value"] = 7 + registry = {} + registry["n"] = 9 + result = (Settings.lower, Settings.upper, table["value"], Settings.registry["n"]) + """) + + state = {} + result, _ = evaluate_python_code(code, BASE_PYTHON_TOOLS, state=state) + + assert result == (2, 7, 7, 9) + + def test_evaluate_class_def_with_tool_named_attributes(self): + """ + Class attributes may shadow built-in tool names (e.g. min/max): they + live on the class, so the tool-rebinding guard must not fire there. + """ + code = dedent(""" + class Limits: + min = 0 + max = 10 + lo, hi = (0, 10) + result = (Limits.min, Limits.max, Limits.lo, Limits.hi) + """) + + state = {} + result, _ = evaluate_python_code(code, BASE_PYTHON_TOOLS, state=state) + + assert result == (0, 10, 0, 10) + # module-level guard still applies: rebinding a tool name raises + with pytest.raises(InterpreterError): + evaluate_python_code("min = 5", BASE_PYTHON_TOOLS, state={}) + def test_evaluate_constant(self): code = "x = 3" state = {}