From 6c79a3d6aa18c492881cf8f5c3f6b07259d56baf Mon Sep 17 00:00:00 2001 From: VICTOR CORREA GOMES <112295415+Vigtu@users.noreply.github.com> Date: Mon, 20 Jan 2025 08:54:32 -0300 Subject: [PATCH] refactor(tools): overhaul Python REPL component with modern tool mode (#5463) * refactor(tools): overhaul Python REPL component with modern tool mode BREAKING CHANGE: Complete redesign of PythonREPL component architecture - Replace legacy tool mode with modern tool_mode=true implementation - Add automatic import detection for both global and from-imports - Remove manual global_imports field in favor of automatic detection - Implement CodeInput type support with FieldTypes.CODE - Update schema to handle new parameters for agent compatibility - Improve error handling and logging for import failures The component now automatically handles imports without manual configuration, supports modern tool mode, and provides better integration with the agent system through updated schema definitions. * refactor(python-repl): remove name parameter * feat(security): add required global imports validation - Add explicit global imports input for security control - Set both global_imports and python_code as required fields - Remove AST-based import analysis in favor of explicit imports * [autofix.ci] apply automated fixes * fix: remove generic exception handling in python_repl Removed overly broad exception handling to comply with linting rules. * fix * test(python-repl): update component test to match current implementation - Remove assertions for deprecated outputs (api_run_model, api_build_tool) - Add assertions for current 'results' output - Add detailed input validation tests for global_imports and python_code - Verify input configurations including type, default values, and required status - Ensure component template structure matches frontend requirements This change aligns the test suite with the current PythonREPLToolComponent implementation, improving test coverage and maintaining component reliability. * revert(tools): restore Python REPL component to original implementation Due to backward compatibility concerns, reverting the Python REPL component to its initial implementation state to maintain stability and prevent breaking changes. * feat(tools): mark Python REPL component as deprecated & Legacy * feat(tools): add Python REPL Core component * [autofix.ci] apply automated fixes * fix(tests): mark Python REPL Core as unreleased component * fix(__init__): fix * feat(python-repl): improve example clarity for printing the results * fix(tests): resolve KeyError in test_component_versions * style(python-repl): format description string to follow PEP 8 guidelines --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Edwin Jose --- .../langflow/components/tools/__init__.py | 2 + .../langflow/components/tools/python_repl.py | 3 +- .../components/tools/python_repl_core.py | 99 +++++++++++++++++++ src/backend/base/langflow/io/schema.py | 1 + .../components/tools/test_python_repl_tool.py | 75 +++++++++----- 5 files changed, 153 insertions(+), 27 deletions(-) create mode 100644 src/backend/base/langflow/components/tools/python_repl_core.py diff --git a/src/backend/base/langflow/components/tools/__init__.py b/src/backend/base/langflow/components/tools/__init__.py index 7f6ffd042..e405ce6f4 100644 --- a/src/backend/base/langflow/components/tools/__init__.py +++ b/src/backend/base/langflow/components/tools/__init__.py @@ -15,6 +15,7 @@ from .google_serper_api_core import GoogleSerperAPICore from .mcp_stdio import MCPStdio from .python_code_structured_tool import PythonCodeStructuredTool from .python_repl import PythonREPLToolComponent +from .python_repl_core import PythonREPLComponent from .search_api import SearchAPIComponent from .searxng import SearXNGToolComponent from .serp import SerpComponent @@ -47,6 +48,7 @@ __all__ = [ "GoogleSerperAPICore", "MCPStdio", "PythonCodeStructuredTool", + "PythonREPLComponent", "PythonREPLToolComponent", "SearXNGToolComponent", "SearchAPIComponent", diff --git a/src/backend/base/langflow/components/tools/python_repl.py b/src/backend/base/langflow/components/tools/python_repl.py index ba11e49b7..0f453e41d 100644 --- a/src/backend/base/langflow/components/tools/python_repl.py +++ b/src/backend/base/langflow/components/tools/python_repl.py @@ -13,10 +13,11 @@ from langflow.schema import Data class PythonREPLToolComponent(LCToolComponent): - display_name = "Python REPL" + display_name = "Python REPL [DEPRECATED]" description = "A tool for running Python code in a REPL environment." name = "PythonREPLTool" icon = "Python" + legacy = True inputs = [ StrInput( diff --git a/src/backend/base/langflow/components/tools/python_repl_core.py b/src/backend/base/langflow/components/tools/python_repl_core.py new file mode 100644 index 000000000..ea88f55e8 --- /dev/null +++ b/src/backend/base/langflow/components/tools/python_repl_core.py @@ -0,0 +1,99 @@ +import importlib + +from langchain_experimental.utilities import PythonREPL + +from langflow.custom import Component +from langflow.io import CodeInput, Output, StrInput +from langflow.schema import Data + + +class PythonREPLComponent(Component): + display_name = "Python REPL" + description = ( + "A Python code executor that lets you run Python code with specific imported modules. " + "Remember to always use print() to see your results. Example: print(df.head())" + ) + icon = "Python" + + inputs = [ + StrInput( + name="global_imports", + display_name="Global Imports", + info="A comma-separated list of modules to import globally, e.g. 'math,numpy,pandas'.", + value="math,pandas", + required=True, + ), + CodeInput( + name="python_code", + display_name="Python Code", + info="The Python code to execute. Only modules specified in Global Imports can be used.", + value="print('Hello, World!')", + tool_mode=True, + required=True, + ), + ] + + outputs = [ + Output( + display_name="Results", + name="results", + type_=Data, + method="run_python_repl", + ), + ] + + def get_globals(self, global_imports: str | list[str]) -> dict: + """Create a globals dictionary with only the specified allowed imports.""" + global_dict = {} + + try: + if isinstance(global_imports, str): + modules = [module.strip() for module in global_imports.split(",")] + elif isinstance(global_imports, list): + modules = global_imports + else: + msg = "global_imports must be either a string or a list" + raise TypeError(msg) + + for module in modules: + try: + imported_module = importlib.import_module(module) + global_dict[imported_module.__name__] = imported_module + except ImportError as e: + msg = f"Could not import module {module}: {e!s}" + raise ImportError(msg) from e + + except Exception as e: + self.log(f"Error in global imports: {e!s}") + raise + else: + self.log(f"Successfully imported modules: {list(global_dict.keys())}") + return global_dict + + def run_python_repl(self) -> Data: + try: + globals_ = self.get_globals(self.global_imports) + python_repl = PythonREPL(_globals=globals_) + result = python_repl.run(self.python_code) + result = result.strip() if result else "" + + self.log("Code execution completed successfully") + return Data(data={"result": result}) + + except ImportError as e: + error_message = f"Import Error: {e!s}" + self.log(error_message) + return Data(data={"error": error_message}) + + except SyntaxError as e: + error_message = f"Syntax Error: {e!s}" + self.log(error_message) + return Data(data={"error": error_message}) + + except (NameError, TypeError, ValueError) as e: + error_message = f"Error during execution: {e!s}" + self.log(error_message) + return Data(data={"error": error_message}) + + def build(self): + return self.run_python_repl diff --git a/src/backend/base/langflow/io/schema.py b/src/backend/base/langflow/io/schema.py index b92c6fe2e..3feded3e8 100644 --- a/src/backend/base/langflow/io/schema.py +++ b/src/backend/base/langflow/io/schema.py @@ -15,6 +15,7 @@ _convert_field_type_to_type: dict[FieldTypes, type] = { FieldTypes.TABLE: dict, FieldTypes.FILE: str, FieldTypes.PROMPT: str, + FieldTypes.CODE: str, FieldTypes.OTHER: str, } diff --git a/src/backend/tests/unit/components/tools/test_python_repl_tool.py b/src/backend/tests/unit/components/tools/test_python_repl_tool.py index d0f0921b7..98f2b7899 100644 --- a/src/backend/tests/unit/components/tools/test_python_repl_tool.py +++ b/src/backend/tests/unit/components/tools/test_python_repl_tool.py @@ -1,30 +1,53 @@ -from langflow.components.tools import PythonREPLToolComponent -from langflow.custom import Component -from langflow.custom.utils import build_custom_component_template +import pytest +from langflow.components.tools import PythonREPLComponent + +from tests.base import DID_NOT_EXIST, ComponentTestBaseWithoutClient -def test_python_repl_tool_template(): - python_repl_tool = PythonREPLToolComponent() - component = Component(_code=python_repl_tool._code) - frontend_node, _ = build_custom_component_template(component) - assert "outputs" in frontend_node - output_names = [output["name"] for output in frontend_node["outputs"]] - assert "api_run_model" in output_names - assert "api_build_tool" in output_names - assert all(output["types"] != [] for output in frontend_node["outputs"]) +class TestPythonREPLComponent(ComponentTestBaseWithoutClient): + @pytest.fixture + def component_class(self): + return PythonREPLComponent - # Additional assertions specific to PythonREPLToolComponent - input_names = [input_["name"] for input_ in frontend_node["template"].values() if isinstance(input_, dict)] - # assert "input_value" in input_names - assert "name" in input_names - assert "description" in input_names - assert "global_imports" in input_names + @pytest.fixture + def default_kwargs(self): + return { + "global_imports": "math", + "python_code": "print('Hello, World!')", + } - global_imports_input = next( - input_ - for input_ in frontend_node["template"].values() - if isinstance(input_, dict) and input_["name"] == "global_imports" - ) - assert global_imports_input["type"] == "str" - # assert global_imports_input["combobox"] is True - assert global_imports_input["value"] == "math" + @pytest.fixture + def file_names_mapping(self): + # Component not yet released, mark all versions as non-existent + return [ + {"version": "1.0.17", "module": "tools", "file_name": DID_NOT_EXIST}, + {"version": "1.0.18", "module": "tools", "file_name": DID_NOT_EXIST}, + {"version": "1.0.19", "module": "tools", "file_name": DID_NOT_EXIST}, + {"version": "1.1.0", "module": "tools", "file_name": DID_NOT_EXIST}, + {"version": "1.1.1", "module": "tools", "file_name": DID_NOT_EXIST}, + ] + + def test_component_initialization(self, component_class, default_kwargs): + component = component_class(**default_kwargs) + frontend_node = component.to_frontend_node() + node_data = frontend_node["data"]["node"] + + # Test template fields + template = node_data["template"] + assert "global_imports" in template + assert "python_code" in template + + # Test global_imports configuration + global_imports = template["global_imports"] + assert global_imports["type"] == "str" + assert global_imports["value"] == "math" + assert global_imports["required"] is True + + # Test python_code configuration + python_code = template["python_code"] + assert python_code["type"] == "code" + assert python_code["value"] == "print('Hello, World!')" + assert python_code["required"] is True + + # Test base configuration + assert "Data" in node_data["base_classes"]