feat(utils): add code hash generation and module name in Custom Components (#9107)

* refactor: update _generate_code_hash function and enhance module name handling

- Removed the class_name parameter from _generate_code_hash for improved clarity and simplicity.
- Added a new function, get_module_name_from_display_name, to generate module names from display names in snake_case.
- Updated build_custom_component_template_from_inputs to use the new module name generation logic when module_name is None.
- Enhanced error handling in code hash generation to log exceptions appropriately.
- Updated unit tests to reflect changes in the _generate_code_hash function and to verify the new module name generation functionality.

* fix: enhance module name handling and error logging in build_custom_component_template

- Added logic to derive module names from display names when not provided, improving metadata accuracy.
- Refined error handling for code hash generation, ensuring exceptions are logged appropriately for better debugging.

* test: add comprehensive unit tests for metadata generation in custom components

- Introduced multiple tests to ensure that the `build_custom_component_template` function consistently generates metadata, including module names and code hashes, across various scenarios.
- Verified that metadata is correctly returned when module names are provided or omitted, and that code hashes change with component code modifications.
- Included tests for handling unicode characters in component code to ensure robustness in metadata generation.

* test: update unit tests to use Component class for metadata generation

- Refactored test cases to replace the CustomComponent with the new Component class, ensuring consistency in testing metadata addition in template builders.
- Adjusted mock component attributes to align with the updated class structure, enhancing clarity and maintainability of the tests.

* test: add unit tests for custom component metadata retrieval and consistency

- Introduced new tests for the /custom_component endpoint to verify that it returns accurate metadata, including module names and code hashes.
- Ensured that identical component code produces consistent metadata across multiple requests, enhancing the reliability of the custom component functionality.

* refactor: improve error logging in code hash generation

- Updated error logging in `build_custom_component_template_from_inputs` and `build_custom_component_template` to use debug level with exception context, enhancing clarity for debugging while reducing log noise.
- This change aims to provide more detailed insights during error occurrences without cluttering the error logs.
This commit is contained in:
Gabriel Luiz Freitas Almeida 2025-08-18 10:56:29 -03:00 committed by GitHub
commit 2c405f77e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 437 additions and 21 deletions

View file

@ -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:

View file

@ -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"

View file

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

View file

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