diff --git a/src/backend/base/langflow/custom/utils.py b/src/backend/base/langflow/custom/utils.py index 9f78efe91..60e875593 100644 --- a/src/backend/base/langflow/custom/utils.py +++ b/src/backend/base/langflow/custom/utils.py @@ -33,13 +33,12 @@ from langflow.utils import validate from langflow.utils.util import get_base_classes -def _generate_code_hash(source_code: str, modname: str, class_name: str) -> str: +def _generate_code_hash(source_code: str, modname: str) -> str: """Generate a hash of the component source code. Args: source_code: The source code string modname: The module name for context - class_name: The class name for context Returns: SHA256 hash of the source code @@ -50,7 +49,7 @@ def _generate_code_hash(source_code: str, modname: str, class_name: str) -> str: TypeError: If source_code is not a string """ if not source_code: - msg = f"Empty source code for {class_name} in {modname}" + msg = f"Empty source code for {modname}" raise ValueError(msg) # Generate SHA256 hash of the source code @@ -439,6 +438,18 @@ def add_code_field_to_build_config(build_config: dict, raw_code: str): return build_config +def get_module_name_from_display_name(display_name: str): + """Get the module name from the display name.""" + # Convert display name to snake_case for Python module name + # e.g., "Custom Component" -> "custom_component" + # Remove extra spaces and convert to lowercase + cleaned_name = re.sub(r"\s+", " ", display_name.strip()) + # Replace spaces with underscores and convert to lowercase + module_name = cleaned_name.replace(" ", "_").lower() + # Remove any non-alphanumeric characters except underscores + return re.sub(r"[^a-z0-9_]", "", module_name) + + def build_custom_component_template_from_inputs( custom_component: Component | CustomComponent, user_id: str | UUID | None = None, module_name: str | None = None ): @@ -479,11 +490,17 @@ def build_custom_component_template_from_inputs( reorder_fields(frontend_node, cc_instance._get_field_order()) if module_name: frontend_node.metadata["module"] = module_name + else: + module_name = get_module_name_from_display_name(frontend_node.display_name) + frontend_node.metadata["module"] = f"custom_components.{module_name}" - # Generate code hash for cache invalidation and debugging - code_hash = _generate_code_hash(custom_component._code, module_name, ctype_name) + # Generate code hash for cache invalidation and debugging + try: + code_hash = _generate_code_hash(custom_component._code, module_name) if code_hash: frontend_node.metadata["code_hash"] = code_hash + except Exception as exc: # noqa: BLE001 + logger.opt(exception=exc).debug(f"Error generating code hash for {custom_component.__class__.__name__}") return frontend_node.to_dict(keep_name=False), cc_instance @@ -546,11 +563,17 @@ def build_custom_component_template( if module_name: frontend_node.metadata["module"] = module_name + else: + module_name = get_module_name_from_display_name(frontend_node.display_name) + frontend_node.metadata["module"] = f"custom_components.{module_name}" - # Generate code hash for cache invalidation and debugging - code_hash = _generate_code_hash(custom_component._code, module_name, custom_component.__class__.__name__) + # Generate code hash for cache invalidation and debugging + try: + code_hash = _generate_code_hash(custom_component._code, module_name) if code_hash: frontend_node.metadata["code_hash"] = code_hash + except Exception as exc: # noqa: BLE001 + logger.opt(exception=exc).debug(f"Error generating code hash for {custom_component.__class__.__name__}") return frontend_node.to_dict(keep_name=False), custom_instance except Exception as exc: diff --git a/src/backend/tests/unit/api/v1/test_endpoints.py b/src/backend/tests/unit/api/v1/test_endpoints.py index 79ada3949..d36246978 100644 --- a/src/backend/tests/unit/api/v1/test_endpoints.py +++ b/src/backend/tests/unit/api/v1/test_endpoints.py @@ -5,7 +5,7 @@ from typing import Any from anyio import Path from fastapi import status from httpx import AsyncClient -from langflow.api.v1.schemas import UpdateCustomComponentRequest +from langflow.api.v1.schemas import CustomComponentRequest, UpdateCustomComponentRequest from langflow.components.agents.agent import AgentComponent from langflow.custom.utils import build_custom_component_template @@ -106,3 +106,89 @@ async def test_update_component_model_name_options(client: AsyncClient, logged_i assert response.status_code == status.HTTP_200_OK assert "template" in result assert "model_name" not in result["template"] + + +async def test_custom_component_endpoint_returns_metadata(client: AsyncClient, logged_in_headers: dict): + """Test that the /custom_component endpoint returns metadata with module and code_hash.""" + component_code = """ +from langflow.custom import Component +from langflow.inputs.inputs import MessageTextInput +from langflow.template.field.base import Output + +class TestMetadataComponent(Component): + display_name = "Test Metadata Component" + description = "Test component for metadata" + + inputs = [ + MessageTextInput(display_name="Input", name="input_value"), + ] + outputs = [ + Output(display_name="Output", name="output", method="process_input"), + ] + + def process_input(self) -> str: + return f"Processed: {self.input_value}" +""" + + request = CustomComponentRequest(code=component_code) + response = await client.post("api/v1/custom_component", json=request.model_dump(), headers=logged_in_headers) + result = response.json() + + assert response.status_code == status.HTTP_200_OK + assert "data" in result + assert "type" in result + + # Verify metadata is present in the response + frontend_node = result["data"] + assert "metadata" in frontend_node, "Frontend node should contain metadata" + + metadata = frontend_node["metadata"] + assert "module" in metadata, "Metadata should contain module field" + assert "code_hash" in metadata, "Metadata should contain code_hash field" + + # Verify metadata values + assert isinstance(metadata["module"], str), "Module should be a string" + expected_module = "custom_components.test_metadata_component" + assert metadata["module"] == expected_module, "Module should be auto-generated from display_name" + + assert isinstance(metadata["code_hash"], str), "Code hash should be a string" + assert len(metadata["code_hash"]) == 12, "Code hash should be 12 characters long" + assert all(c in "0123456789abcdef" for c in metadata["code_hash"]), "Code hash should be hexadecimal" + + +async def test_custom_component_endpoint_metadata_consistency(client: AsyncClient, logged_in_headers: dict): + """Test that the same component code produces consistent metadata.""" + component_code = """ +from langflow.custom import Component +from langflow.template.field.base import Output + +class ConsistencyTestComponent(Component): + display_name = "Consistency Test" + + outputs = [ + Output(display_name="Output", name="output", method="get_result"), + ] + + def get_result(self) -> str: + return "consistent result" +""" + + # Make two identical requests + request = CustomComponentRequest(code=component_code) + + response1 = await client.post("api/v1/custom_component", json=request.model_dump(), headers=logged_in_headers) + result1 = response1.json() + + response2 = await client.post("api/v1/custom_component", json=request.model_dump(), headers=logged_in_headers) + result2 = response2.json() + + # Both requests should succeed + assert response1.status_code == status.HTTP_200_OK + assert response2.status_code == status.HTTP_200_OK + + # Metadata should be identical + metadata1 = result1["data"]["metadata"] + metadata2 = result2["data"]["metadata"] + + assert metadata1["module"] == metadata2["module"], "Module names should be consistent" + assert metadata1["code_hash"] == metadata2["code_hash"], "Code hashes should be consistent for identical code" diff --git a/src/backend/tests/unit/custom/test_utils_metadata.py b/src/backend/tests/unit/custom/test_utils_metadata.py index 58772a7d1..015e9b1b0 100644 --- a/src/backend/tests/unit/custom/test_utils_metadata.py +++ b/src/backend/tests/unit/custom/test_utils_metadata.py @@ -13,9 +13,8 @@ class TestCodeHashGeneration: """Test basic hash generation.""" source = "def test(): pass" modname = "test_module" - class_name = "TestClass" - result = _generate_code_hash(source, modname, class_name) + result = _generate_code_hash(source, modname) assert isinstance(result, str) assert len(result) == 12 @@ -24,24 +23,24 @@ class TestCodeHashGeneration: def test_hash_empty_source_raises(self): """Test that empty source raises ValueError.""" with pytest.raises(ValueError, match="Empty source code"): - _generate_code_hash("", "mod", "cls") + _generate_code_hash("", "mod") def test_hash_none_source_raises(self): """Test that None source raises ValueError.""" with pytest.raises(ValueError, match="Empty source code"): - _generate_code_hash(None, "mod", "cls") + _generate_code_hash(None, "mod") def test_hash_consistency(self): """Test that same code produces same hash.""" source = "class A: pass" - hash1 = _generate_code_hash(source, "mod", "A") - hash2 = _generate_code_hash(source, "mod", "A") + hash1 = _generate_code_hash(source, "mod") + hash2 = _generate_code_hash(source, "mod") assert hash1 == hash2 def test_hash_different_code(self): """Test that different code produces different hash.""" - hash1 = _generate_code_hash("class A: pass", "mod", "A") - hash2 = _generate_code_hash("class B: pass", "mod", "B") + hash1 = _generate_code_hash("class A: pass", "mod") + hash2 = _generate_code_hash("class B: pass", "mod") assert hash1 != hash2 @@ -61,6 +60,7 @@ class TestMetadataInTemplateBuilders: mock_frontend.to_dict = Mock(return_value={"test": "data"}) mock_frontend.validate_component = Mock() mock_frontend.set_base_classes_from_outputs = Mock() + mock_frontend.display_name = "Test Component" mock_frontend_class.from_inputs.return_value = mock_frontend # Create test component @@ -93,7 +93,7 @@ class TestMetadataInTemplateBuilders: @patch("langflow.custom.utils.CustomComponentFrontendNode") def test_build_template_adds_metadata_with_module(self, mock_frontend_class): """Test that build_custom_component_template adds metadata when module_name is provided.""" - from langflow.custom.custom_component.custom_component import CustomComponent + from langflow.custom.custom_component.component import Component from langflow.custom.utils import build_custom_component_template # Setup mock frontend node @@ -103,9 +103,9 @@ class TestMetadataInTemplateBuilders: mock_frontend_class.return_value = mock_frontend # Create test component - test_component = Mock(spec=CustomComponent) - test_component.__class__.__name__ = "CustomTestComponent" - test_component._code = "class CustomTestComponent: pass" + test_component = Mock(spec=Component) + test_component.__class__.__name__ = "TestComponent" + test_component._code = "class TestComponent: pass" test_component.template_config = {"display_name": "Test"} test_component.get_function_entrypoint_args = [] test_component._get_function_entrypoint_return_type = [] @@ -135,8 +135,51 @@ class TestMetadataInTemplateBuilders: def test_hash_generation_unicode(self): """Test hash generation with unicode characters.""" source = "# Test with unicode: 你好 🌟\nclass Component: pass" - result = _generate_code_hash(source, "unicode_mod", "Component") + result = _generate_code_hash(source, "unicode_mod") assert isinstance(result, str) assert len(result) == 12 assert all(c in "0123456789abcdef" for c in result) + + @patch("langflow.custom.utils.ComponentFrontendNode") + def test_build_from_inputs_without_module_generates_default(self, mock_frontend_class): + """Test that build_custom_component_template_from_inputs generates default module when module_name is None.""" + from langflow.custom.custom_component.component import Component + from langflow.custom.utils import build_custom_component_template_from_inputs + + # Setup mock frontend node + mock_frontend = Mock() + mock_frontend.metadata = {} + mock_frontend.outputs = [] + mock_frontend.to_dict = Mock(return_value={"test": "data"}) + mock_frontend.validate_component = Mock() + mock_frontend.set_base_classes_from_outputs = Mock() + mock_frontend.display_name = "My Test Component" + mock_frontend_class.from_inputs.return_value = mock_frontend + + # Create test component + test_component = Mock(spec=Component) + test_component.__class__.__name__ = "TestComponent" + test_component._code = "class TestComponent: pass" + test_component.template_config = {"inputs": []} + + # Mock get_component_instance to return a mock instance + with patch("langflow.custom.utils.get_component_instance") as mock_get_instance: + mock_instance = Mock() + mock_instance.get_template_config = Mock(return_value={}) + mock_instance._get_field_order = Mock(return_value=[]) + mock_get_instance.return_value = mock_instance + + # Mock add_code_field to return the frontend node + with ( + patch("langflow.custom.utils.add_code_field", return_value=mock_frontend), + patch("langflow.custom.utils.reorder_fields"), + ): + # Call the function without module_name + template, _ = build_custom_component_template_from_inputs(test_component, module_name=None) + + # Verify metadata was added with generated module name + assert "module" in mock_frontend.metadata + assert mock_frontend.metadata["module"] == "custom_components.my_test_component" + assert "code_hash" in mock_frontend.metadata + assert len(mock_frontend.metadata["code_hash"]) == 12 diff --git a/src/backend/tests/unit/test_custom_component.py b/src/backend/tests/unit/test_custom_component.py index 5ee8a26ae..f4a098eb2 100644 --- a/src/backend/tests/unit/test_custom_component.py +++ b/src/backend/tests/unit/test_custom_component.py @@ -433,3 +433,267 @@ def test_custom_component_subclass_from_lctoolcomponent(): assert "outputs" in frontend_node assert frontend_node["outputs"][0]["types"] != [] assert frontend_node["outputs"][1]["types"] != [] + + +def test_build_custom_component_template_includes_metadata_with_module(): + """Test that build_custom_component_template includes metadata when module_name is provided.""" + code = dedent(""" + from langflow.custom import Component + from langflow.inputs.inputs import MessageTextInput + from langflow.template.field.base import Output + + class TestMetadataComponent(Component): + display_name = "Test Metadata Component" + description = "Test component for metadata" + + inputs = [ + MessageTextInput(display_name="Input", name="input_value"), + ] + outputs = [ + Output(display_name="Output", name="output", method="process_input"), + ] + + def process_input(self) -> str: + return f"Processed: {self.input_value}" + """) + + component = Component(_code=code) + frontend_node, _ = build_custom_component_template(component, module_name="test.module") + + # Verify metadata is present + assert "metadata" in frontend_node + metadata = frontend_node["metadata"] + + # Verify metadata contains required fields + assert "module" in metadata + assert "code_hash" in metadata + + # Verify metadata values + assert metadata["module"] == "test.module" + assert isinstance(metadata["code_hash"], str) + assert len(metadata["code_hash"]) == 12 + assert all(c in "0123456789abcdef" for c in metadata["code_hash"]) + + +def test_build_custom_component_template_always_has_metadata(): + """Test that build_custom_component_template always generates metadata, even when module_name is None.""" + code = dedent(""" + from langflow.custom import Component + from langflow.template.field.base import Output + + class TestAlwaysMetadata(Component): + display_name = "Test Always Metadata" + + outputs = [ + Output(display_name="Output", name="output", method="get_result"), + ] + + def get_result(self) -> str: + return "test" + """) + + component = Component(_code=code) + frontend_node, _ = build_custom_component_template(component, module_name=None) + + # Metadata should ALWAYS be present + assert "metadata" in frontend_node + metadata = frontend_node["metadata"] + + assert "module" in metadata + assert "code_hash" in metadata + + # Should generate default module name from display_name + assert metadata["module"] == "custom_components.test_always_metadata" + assert len(metadata["code_hash"]) == 12 + + +def test_build_custom_component_template_metadata_hash_changes(): + """Test that code hash changes when component code changes.""" + code_v1 = dedent(""" + from langflow.custom import Component + from langflow.template.field.base import Output + + class VersionComponent(Component): + display_name = "Version Component" + version = "1.0" + + outputs = [ + Output(display_name="Output", name="output", method="get_version"), + ] + + def get_version(self) -> str: + return "version 1.0" + """) + + code_v2 = dedent(""" + from langflow.custom import Component + from langflow.template.field.base import Output + + class VersionComponent(Component): + display_name = "Version Component" + version = "2.0" + + outputs = [ + Output(display_name="Output", name="output", method="get_version"), + ] + + def get_version(self) -> str: + return "version 2.0" + """) + + component_v1 = Component(_code=code_v1) + component_v2 = Component(_code=code_v2) + + frontend_node_v1, _ = build_custom_component_template(component_v1, module_name="test.version") + frontend_node_v2, _ = build_custom_component_template(component_v2, module_name="test.version") + + metadata_v1 = frontend_node_v1["metadata"] + metadata_v2 = frontend_node_v2["metadata"] + + # Same module name + assert metadata_v1["module"] == metadata_v2["module"] + + # Different code hashes + assert metadata_v1["code_hash"] != metadata_v2["code_hash"] + + +def test_build_custom_component_template_metadata_unicode(): + """Test that metadata generation works with unicode characters in code.""" + code = dedent(""" + from langflow.custom import Component + from langflow.template.field.base import Output + + class UnicodeComponent(Component): + display_name = "Unicode Test 🌟" + description = "测试组件 with émojis" + + outputs = [ + Output(display_name="Output", name="output", method="get_unicode"), + ] + + def get_unicode(self) -> str: + # Comment with unicode: 你好世界 🚀 + return "Hello 世界!" + """) + + component = Component(_code=code) + frontend_node, _ = build_custom_component_template(component, module_name="unicode.test") + + # Verify metadata is present and valid + metadata = frontend_node["metadata"] + assert "module" in metadata + assert "code_hash" in metadata + + # Verify hash is valid hexadecimal + code_hash = metadata["code_hash"] + assert len(code_hash) == 12 + assert all(c in "0123456789abcdef" for c in code_hash) + + +def test_build_custom_component_template_component_always_has_metadata(): + """Test that build_custom_component_template always returns metadata for Component path.""" + code = dedent(""" + from langflow.custom import Component + from langflow.inputs.inputs import MessageTextInput + from langflow.template.field.base import Output + + class TestComponentMetadata(Component): + display_name = "Test Component Metadata" + + inputs = [ + MessageTextInput(display_name="Input", name="input_value"), + ] + outputs = [ + Output(display_name="Output", name="output", method="process_input"), + ] + + def process_input(self) -> str: + return f"Processed: {self.input_value}" + """) + + component = Component(_code=code) + frontend_node, _ = build_custom_component_template(component, module_name=None) + + # Metadata should ALWAYS be present, even for Component without module_name + assert "metadata" in frontend_node + metadata = frontend_node["metadata"] + + assert "module" in metadata + assert "code_hash" in metadata + + # Should generate default module name from display_name + assert metadata["module"] == "custom_components.test_component_metadata" + assert len(metadata["code_hash"]) == 12 + + +def test_metadata_always_returned_comprehensive(): + """Comprehensive test to verify metadata is ALWAYS returned in all scenarios.""" + # Test scenario 1: Component with module_name provided + code1 = dedent(""" + from langflow.custom import Component + from langflow.template.field.base import Output + + class TestWithModule(Component): + display_name = "Test With Module" + + outputs = [ + Output(display_name="Output", name="output", method="get_result"), + ] + + def get_result(self) -> str: + return "with module" + """) + + component1 = Component(_code=code1) + frontend_node1, _ = build_custom_component_template(component1, module_name="explicit.module") + + assert "metadata" in frontend_node1 + assert frontend_node1["metadata"]["module"] == "explicit.module" + assert "code_hash" in frontend_node1["metadata"] + assert len(frontend_node1["metadata"]["code_hash"]) == 12 + + # Test scenario 2: Component without module_name (should generate default) + component2 = Component(_code=code1) + frontend_node2, _ = build_custom_component_template(component2, module_name=None) + + assert "metadata" in frontend_node2 + assert frontend_node2["metadata"]["module"] == "custom_components.test_with_module" + assert "code_hash" in frontend_node2["metadata"] + assert len(frontend_node2["metadata"]["code_hash"]) == 12 + + # Test scenario 3: Component with inputs and outputs + code3 = dedent(""" + from langflow.custom import Component + from langflow.inputs.inputs import MessageTextInput + from langflow.template.field.base import Output + + class TestWithInputs(Component): + display_name = "Test With Inputs" + + inputs = [ + MessageTextInput(display_name="Input", name="input_value"), + ] + outputs = [ + Output(display_name="Output", name="output", method="process_input"), + ] + + def process_input(self) -> str: + return f"Processed: {self.input_value}" + """) + + component3 = Component(_code=code3) + frontend_node3, _ = build_custom_component_template(component3, module_name="custom.explicit") + + assert "metadata" in frontend_node3 + assert frontend_node3["metadata"]["module"] == "custom.explicit" + assert "code_hash" in frontend_node3["metadata"] + assert len(frontend_node3["metadata"]["code_hash"]) == 12 + + # Test scenario 4: Component without module_name (should generate default) + component4 = Component(_code=code3) + frontend_node4, _ = build_custom_component_template(component4, module_name=None) + + assert "metadata" in frontend_node4 + assert frontend_node4["metadata"]["module"] == "custom_components.test_with_inputs" + assert "code_hash" in frontend_node4["metadata"] + assert len(frontend_node4["metadata"]["code_hash"]) == 12