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

View file

@ -3,7 +3,7 @@ from uuid import UUID
import orjson
import pytest
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.schema.data import Data
from langflow.services.database.models.flow import FlowCreate
@ -37,9 +37,9 @@ class TestLoopComponentWithAPI(ComponentTestBaseWithClient):
"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."""
result = LoopComponent(**default_kwargs)
result = component_class(**default_kwargs)
assert result is not None, "Component returned None for the latest version."
async def _create_flow(self, client, json_loop_test, logged_in_headers):

View file

@ -43,53 +43,50 @@ test(
await page
.getByTestId("logicLoop")
.first()
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 280, y: 100 },
});
// Add Update Data component
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("update data");
await page.waitForSelector('[data-testid="processingUpdate Data"]', {
await page.getByTestId("sidebar-search-input").fill("data operations");
await page.waitForSelector('[data-testid="processingData Operations"]', {
timeout: 1000,
});
await page
.getByTestId("processingUpdate Data")
.getByTestId("processingData Operations")
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 500, y: 100 },
});
// Add Parse Data component
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("data to message");
await page.waitForSelector('[data-testid="processingData to Message"]', {
await page.getByTestId("sidebar-search-input").fill("Parser");
await page.waitForSelector('[data-testid="processingParser"]', {
timeout: 1000,
});
await page
.getByTestId("processingData to Message")
.getByTestId("processingParser")
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 720, y: 100 },
});
//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
.getByTestId("processingData to Message")
.getByTestId("dataFile")
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
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
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("chat output");
@ -114,7 +111,7 @@ test(
.first()
.click();
await page
.getByTestId("handle-updatedata-shownode-data-left")
.getByTestId("handle-dataoperations-shownode-data-left")
.first()
.click();
@ -124,7 +121,7 @@ test(
.first()
.click();
await page
.getByTestId("handle-loopcomponent-shownode-data-left")
.getByTestId("handle-loopcomponent-shownode-data or dataframe-left")
.first()
.click();
@ -134,13 +131,13 @@ test(
.first()
.click();
await page
.getByTestId("handle-parsedata-shownode-data-left")
.getByTestId("handle-parsercomponent-shownode-data or dataframe-left")
.first()
.click();
// Parse Data -> Chat Output
await page
.getByTestId("handle-parsedata-shownode-message-right")
.getByTestId("handle-parsercomponent-shownode-parsed text-right")
.first()
.click();
@ -149,7 +146,14 @@ test(
.first()
.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();
@ -180,14 +184,16 @@ test(
.fill("https://en.wikipedia.org/wiki/Human_intelligence");
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("keypair100").fill("modified_value");
// 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.", {
timeout: 30000,
});
@ -197,7 +203,7 @@ 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();
@ -206,7 +212,7 @@ test(
// Update Data -> Loop Item (left side)
await page
.getByTestId("handle-updatedata-shownode-data-right")
.getByTestId("handle-dataoperations-shownode-data-right")
.first()
.click();
await page