diff --git a/src/backend/base/langflow/components/tools/__init__.py b/src/backend/base/langflow/components/tools/__init__.py index 707ecd075..55c99c367 100644 --- a/src/backend/base/langflow/components/tools/__init__.py +++ b/src/backend/base/langflow/components/tools/__init__.py @@ -4,6 +4,7 @@ from langchain_core._api.deprecation import LangChainDeprecationWarning from .bing_search_api import BingSearchAPIComponent from .calculator import CalculatorToolComponent +from .calculator_core import CalculatorComponent from .duck_duck_go_search_run import DuckDuckGoSearchComponent from .exa_search import ExaSearchToolkit from .glean_search_api import GleanSearchAPIComponent @@ -31,6 +32,7 @@ __all__ = [ "AstraDBCQLToolComponent", "AstraDBToolComponent", "BingSearchAPIComponent", + "CalculatorComponent", "CalculatorToolComponent", "DuckDuckGoSearchComponent", "ExaSearchToolkit", diff --git a/src/backend/base/langflow/components/tools/calculator.py b/src/backend/base/langflow/components/tools/calculator.py index 8bd38d756..1d13ed9f0 100644 --- a/src/backend/base/langflow/components/tools/calculator.py +++ b/src/backend/base/langflow/components/tools/calculator.py @@ -13,10 +13,11 @@ from langflow.schema import Data class CalculatorToolComponent(LCToolComponent): - display_name = "Calculator" + display_name = "Calculator [DEPRECATED]" description = "Perform basic arithmetic operations on a given expression." icon = "calculator" name = "CalculatorTool" + legacy = True inputs = [ MessageTextInput( diff --git a/src/backend/base/langflow/components/tools/calculator_core.py b/src/backend/base/langflow/components/tools/calculator_core.py new file mode 100644 index 000000000..0eef657e9 --- /dev/null +++ b/src/backend/base/langflow/components/tools/calculator_core.py @@ -0,0 +1,88 @@ +import ast +import operator +from collections.abc import Callable + +from langflow.custom import Component +from langflow.inputs import MessageTextInput +from langflow.io import Output +from langflow.schema import Data + + +class CalculatorComponent(Component): + display_name = "Calculator" + description = "Perform basic arithmetic operations on a given expression." + icon = "calculator" + + # Cache operators dictionary as a class variable + OPERATORS: dict[type[ast.operator], Callable] = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.Pow: operator.pow, + } + + inputs = [ + MessageTextInput( + name="expression", + display_name="Expression", + info="The arithmetic expression to evaluate (e.g., '4*4*(33/22)+12-20').", + tool_mode=True, + ), + ] + + outputs = [ + Output(display_name="Data", name="result", type_=Data, method="evaluate_expression"), + ] + + def _eval_expr(self, node: ast.AST) -> float: + """Evaluate an AST node recursively.""" + if isinstance(node, ast.Constant): + if isinstance(node.value, int | float): + return float(node.value) + error_msg = f"Unsupported constant type: {type(node.value).__name__}" + raise TypeError(error_msg) + if isinstance(node, ast.Num): # For backwards compatibility + if isinstance(node.n, int | float): + return float(node.n) + error_msg = f"Unsupported number type: {type(node.n).__name__}" + raise TypeError(error_msg) + + if isinstance(node, ast.BinOp): + op_type = type(node.op) + if op_type not in self.OPERATORS: + error_msg = f"Unsupported binary operator: {op_type.__name__}" + raise TypeError(error_msg) + + left = self._eval_expr(node.left) + right = self._eval_expr(node.right) + return self.OPERATORS[op_type](left, right) + + error_msg = f"Unsupported operation or expression type: {type(node).__name__}" + raise TypeError(error_msg) + + def evaluate_expression(self) -> Data: + """Evaluate the mathematical expression and return the result.""" + try: + tree = ast.parse(self.expression, mode="eval") + result = self._eval_expr(tree.body) + + formatted_result = f"{float(result):.6f}".rstrip("0").rstrip(".") + self.log(f"Calculation result: {formatted_result}") + + self.status = formatted_result + return Data(data={"result": formatted_result}) + + except ZeroDivisionError: + error_message = "Error: Division by zero" + self.status = error_message + return Data(data={"error": error_message, "input": self.expression}) + + except (SyntaxError, TypeError, KeyError, ValueError, AttributeError, OverflowError) as e: + error_message = f"Invalid expression: {e!s}" + self.status = error_message + return Data(data={"error": error_message, "input": self.expression}) + + def build(self): + """Return the main evaluation function.""" + return self.evaluate_expression diff --git a/src/backend/tests/unit/components/tools/test_calculator.py b/src/backend/tests/unit/components/tools/test_calculator.py new file mode 100644 index 000000000..98b70226c --- /dev/null +++ b/src/backend/tests/unit/components/tools/test_calculator.py @@ -0,0 +1,84 @@ +import pytest +from langflow.components.tools.calculator_core import CalculatorComponent + +from tests.base import ComponentTestBaseWithoutClient + + +class TestCalculatorComponent(ComponentTestBaseWithoutClient): + @pytest.fixture + def component_class(self): + return CalculatorComponent + + @pytest.fixture + def default_kwargs(self): + return {"expression": "2 + 2", "_session_id": "test_session"} + + @pytest.fixture + def file_names_mapping(self): + return [] + + def test_basic_calculation(self, component_class, default_kwargs): + # Arrange + component = component_class(**default_kwargs) + + # Act + result = component.evaluate_expression() + + # Assert + assert result.data["result"] == "4" + + def test_complex_calculation(self, component_class): + # Arrange + component = component_class(expression="4*4*(33/22)+12-20", _session_id="test_session") + + # Act + result = component.evaluate_expression() + + # Assert + assert float(result.data["result"]) == pytest.approx(16) + + def test_division_by_zero(self, component_class): + # Arrange + component = component_class(expression="1/0", _session_id="test_session") + + # Act + result = component.evaluate_expression() + + # Assert + assert "error" in result.data + assert result.data["error"] == "Error: Division by zero" + + def test_invalid_expression(self, component_class): + # Arrange + component = component_class(expression="2 + *", _session_id="test_session") + + # Act + result = component.evaluate_expression() + + # Assert + assert "error" in result.data + assert "Invalid expression" in result.data["error"] + + def test_unsupported_operation(self, component_class): + # Arrange + component = component_class(expression="sqrt(16)", _session_id="test_session") + + # Act + result = component.evaluate_expression() + + # Assert + assert "error" in result.data + assert "Unsupported operation" in result.data["error"] + + def test_component_frontend_node(self, component_class, default_kwargs): + # Arrange + component = component_class(**default_kwargs) + + # Act + frontend_node = component.to_frontend_node() + + # Assert + node_data = frontend_node["data"]["node"] + assert node_data["display_name"] == "Calculator" + assert node_data["description"] == "Perform basic arithmetic operations on a given expression." + assert node_data["icon"] == "calculator"