feat: Loop uplift dataframe input and output (#8177)

* tests cases

* update to loop

* Update component.py

* 📝 (LoopTemplate.json): update value of a configuration key from "OPENAI_API_KEY" to "ANTHROPIC_API_KEY" in order to reflect the correct API key being used

* update json test loop

* add dataframe support for the loop component

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* fix: starter project

* update loop component and tests

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* update logic

* Update loop_basic.py

* Update Research Translation Loop.json

* fix lint

* format fix

* [autofix.ci] apply automated fixes

* reverting changes in component and vertex base

* [autofix.ci] apply automated fixes

* fix lint errors

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* update in the loop templates and component

* [autofix.ci] apply automated fixes

* Update Research Translation Loop.json

* update tests

* update the code and deprecate the old loop

* [autofix.ci] apply automated fixes

* Update loop_basic.py

* WIP FIX Loop Tests

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

* ✨ (loop-component.spec.ts): Update test cases to use more descriptive names for components and actions for better clarity and understanding.

* ✨ (loop-component.spec.ts): refactor loop component tests to improve readability and maintainability by updating test selectors and removing redundant test steps

* update

* Update loop-component.spec.ts

* Update Research Translation Loop.json

* Update Research Translation Loop.json

* Update Research Translation Loop.json

* Update Research Translation Loop.json

* loop test fix

---------

Co-authored-by: cristhianzl <cristhian.lousa@gmail.com>
Co-authored-by: Rodrigo <rodrigosilvanader@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: italojohnny <italojohnnydosanjos@gmail.com>
Co-authored-by: Mike Fortman <michael.fortman@datastax.com>
This commit is contained in:
Edwin Jose 2025-05-22 18:44:48 -04:00 • committed by GitHub
commit 70b8f29099
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 339 additions and 516 deletions

View file

@ -1,6 +1,7 @@
from langflow.custom import Component from langflow.custom import Component
from langflow.io import DataInput, Output from langflow.io import HandleInput, Output
from langflow.schema import Data from langflow.schema import Data
from langflow.schema.dataframe import DataFrame
class LoopComponent(Component): class LoopComponent(Component):
@ -11,10 +12,11 @@ class LoopComponent(Component):
icon = "infinity" icon = "infinity"
inputs = [ inputs = [
DataInput( HandleInput(
name="data", name="data",
display_name="Data", display_name="Data or DataFrame",
info="The initial list of Data objects to iterate over.", info="The initial list of Data objects or DataFrame to iterate over.",
input_types=["Data", "DataFrame"],
), ),
] ]
@ -43,11 +45,13 @@ class LoopComponent(Component):
def _validate_data(self, data): def _validate_data(self, data):
"""Validate and return a list of Data objects.""" """Validate and return a list of Data objects."""
if isinstance(data, DataFrame):
return data.to_data_list()
if isinstance(data, Data): if isinstance(data, Data):
return [data] return [data]
if isinstance(data, list) and all(isinstance(item, Data) for item in data): if isinstance(data, list) and all(isinstance(item, Data) for item in data):
return data return data
msg = "The 'data' input must be a list of Data objects or a single Data object." msg = "The 'data' input must be a DataFrame, a list of Data objects, or a single Data object."
raise TypeError(msg) raise TypeError(msg)
def evaluate_stop_loop(self) -> bool: def evaluate_stop_loop(self) -> bool:
@ -77,7 +81,7 @@ class LoopComponent(Component):
self.update_ctx({f"{self._id}_index": current_index + 1}) self.update_ctx({f"{self._id}_index": current_index + 1})
return current_item return current_item
def done_output(self) -> Data: def done_output(self) -> DataFrame:
"""Trigger the done output when iteration is complete.""" """Trigger the done output when iteration is complete."""
self.initialize_data() self.initialize_data()
@ -85,9 +89,11 @@ class LoopComponent(Component):
self.stop("item") self.stop("item")
self.start("done") self.start("done")
return self.ctx.get(f"{self._id}_aggregated", []) aggregated = self.ctx.get(f"{self._id}_aggregated", [])
return DataFrame(aggregated)
self.stop("done") self.stop("done")
return Data(text="") return DataFrame([])
def loop_variables(self): def loop_variables(self):
"""Retrieve loop variables from context.""" """Retrieve loop variables from context."""
@ -96,16 +102,15 @@ class LoopComponent(Component):
self.ctx.get(f"{self._id}_index", 0), self.ctx.get(f"{self._id}_index", 0),
) )
def aggregated_output(self) -> Data: def aggregated_output(self) -> list[Data]:
"""Return the aggregated list once all items are processed.""" """Return the aggregated list once all items are processed."""
self.initialize_data() self.initialize_data()
# Get data list and aggregated list # Get data list and aggregated list
data_list = self.ctx.get(f"{self._id}_data", []) data_list = self.ctx.get(f"{self._id}_data", [])
aggregated = self.ctx.get(f"{self._id}_aggregated", []) aggregated = self.ctx.get(f"{self._id}_aggregated", [])
loop_input = self.item
# Check if loop input is provided and append to aggregated list if loop_input is not None and not isinstance(loop_input, str) and len(aggregated) <= len(data_list):
if self.item is not None and not isinstance(self.item, str) and len(aggregated) <= len(data_list): aggregated.append(loop_input)
aggregated.append(self.item)
self.update_ctx({f"{self._id}_aggregated": aggregated}) self.update_ctx({f"{self._id}_aggregated": aggregated})
return aggregated return aggregated

View file

@ -3,7 +3,7 @@ from uuid import UUID
import orjson import orjson
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from langflow.components.logic.loop import LoopComponent from langflow.components.logic import LoopComponent
from langflow.memory import aget_messages from langflow.memory import aget_messages
from langflow.schema.data import Data from langflow.schema.data import Data
from langflow.services.database.models.flow import FlowCreate from langflow.services.database.models.flow import FlowCreate
@ -37,9 +37,9 @@ class TestLoopComponentWithAPI(ComponentTestBaseWithClient):
"loop_input": [Data(text=TEXT)], "loop_input": [Data(text=TEXT)],
} }
def test_latest_version(self, default_kwargs) -> None: def test_latest_version(self, component_class, default_kwargs) -> None:
"""Test that the component works with the latest version.""" """Test that the component works with the latest version."""
result = LoopComponent(**default_kwargs) result = component_class(**default_kwargs)
assert result is not None, "Component returned None for the latest version." assert result is not None, "Component returned None for the latest version."
async def _create_flow(self, client, json_loop_test, logged_in_headers): async def _create_flow(self, client, json_loop_test, logged_in_headers):

View file

@ -43,53 +43,50 @@ test(
await page await page
.getByTestId("logicLoop") .getByTestId("logicLoop")
.first()
.dragTo(page.locator('//*[@id="react-flow-id"]'), { .dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 280, y: 100 }, targetPosition: { x: 280, y: 100 },
}); });
// Add Update Data component // Add Update Data component
await page.getByTestId("sidebar-search-input").click(); await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("update data"); await page.getByTestId("sidebar-search-input").fill("data operations");
await page.waitForSelector('[data-testid="processingUpdate Data"]', { await page.waitForSelector('[data-testid="processingData Operations"]', {
timeout: 1000, timeout: 1000,
}); });
await page await page
.getByTestId("processingUpdate Data") .getByTestId("processingData Operations")
.dragTo(page.locator('//*[@id="react-flow-id"]'), { .dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 500, y: 100 }, targetPosition: { x: 500, y: 100 },
}); });
// Add Parse Data component // Add Parse Data component
await page.getByTestId("sidebar-search-input").click(); await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("data to message"); await page.getByTestId("sidebar-search-input").fill("Parser");
await page.waitForSelector('[data-testid="processingData to Message"]', { await page.waitForSelector('[data-testid="processingParser"]', {
timeout: 1000, timeout: 1000,
}); });
await page await page
.getByTestId("processingData to Message") .getByTestId("processingParser")
.dragTo(page.locator('//*[@id="react-flow-id"]'), { .dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 720, y: 100 }, targetPosition: { x: 720, y: 100 },
}); });
//This one is for testing the wrong loop message //This one is for testing the wrong loop message
await page.getByTestId("sidebar-search-input").fill("File");
await page.waitForSelector('[data-testid="dataFile"]', {
timeout: 1000,
});
await page await page
.getByTestId("processingData to Message") .getByTestId("dataFile")
.dragTo(page.locator('//*[@id="react-flow-id"]'), { .dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 720, y: 400 }, targetPosition: { x: 720, y: 400 },
}); });
await page
.getByTestId("handle-parsedata-shownode-data list-right")
.nth(1)
.click();
const loopItemInput = await page
.getByTestId("handle-loopcomponent-shownode-item-left")
.first()
.click();
// Add Chat Output component // Add Chat Output component
await page.getByTestId("sidebar-search-input").click(); await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("chat output"); await page.getByTestId("sidebar-search-input").fill("chat output");
@ -114,7 +111,7 @@ test(
.first() .first()
.click(); .click();
await page await page
.getByTestId("handle-updatedata-shownode-data-left") .getByTestId("handle-dataoperations-shownode-data-left")
.first() .first()
.click(); .click();
@ -124,7 +121,7 @@ test(
.first() .first()
.click(); .click();
await page await page
.getByTestId("handle-loopcomponent-shownode-data-left") .getByTestId("handle-loopcomponent-shownode-data or dataframe-left")
.first() .first()
.click(); .click();
@ -134,13 +131,13 @@ test(
.first() .first()
.click(); .click();
await page await page
.getByTestId("handle-parsedata-shownode-data-left") .getByTestId("handle-parsercomponent-shownode-data or dataframe-left")
.first() .first()
.click(); .click();
// Parse Data -> Chat Output // Parse Data -> Chat Output
await page await page
.getByTestId("handle-parsedata-shownode-message-right") .getByTestId("handle-parsercomponent-shownode-parsed text-right")
.first() .first()
.click(); .click();
@ -149,7 +146,14 @@ test(
.first() .first()
.click(); .click();
await zoomOut(page, 4); //Loop to File
await page
.getByTestId("handle-loopcomponent-shownode-item-left")
.first()
.click();
await page.getByTestId("handle-file-shownode-data-right").first().click();
await zoomOut(page, 3);
await page.getByTestId("div-generic-node").nth(5).click(); await page.getByTestId("div-generic-node").nth(5).click();
@ -180,14 +184,16 @@ test(
.fill("https://en.wikipedia.org/wiki/Human_intelligence"); .fill("https://en.wikipedia.org/wiki/Human_intelligence");
await page.getByTestId("div-generic-node").nth(2).click(); await page.getByTestId("div-generic-node").nth(2).click();
await page.getByTestId("int_int_number_of_fields").fill("1");
await page.getByTestId("div-generic-node").nth(2).click(); await page.getByTestId("button_open_list_selection").click();
await page.getByTestId("list_item_append_or_update").click();
await page.getByTestId("keypair0").fill("text"); await page.getByTestId("keypair0").fill("text");
await page.getByTestId("keypair100").fill("modified_value"); await page.getByTestId("keypair100").fill("modified_value");
// Build and run, expect the wrong loop message // Build and run, expect the wrong loop message
await page.getByTestId("button_run_chat output").click(); await page.getByTestId("button_run_file").click();
await page.waitForSelector("text=The flow has an incomplete loop.", { await page.waitForSelector("text=The flow has an incomplete loop.", {
timeout: 30000, timeout: 30000,
}); });
@ -197,7 +203,7 @@ test(
// Delete the second parse data used to test // Delete the second parse data used to test
await page.getByTestId("div-generic-node").nth(4).click(); await page.getByTestId("title-File").last().click();
await page.getByTestId("more-options-modal").click(); await page.getByTestId("more-options-modal").click();
@ -206,7 +212,7 @@ test(
// Update Data -> Loop Item (left side) // Update Data -> Loop Item (left side)
await page await page
.getByTestId("handle-updatedata-shownode-data-right") .getByTestId("handle-dataoperations-shownode-data-right")
.first() .first()
.click(); .click();
await page await page