Skip to content

Commit c74502e

Browse files
feat: Support appending files when saving (#10631)
* feat: Support appending files when saving * Update save_file.py * Update News Aggregator.json * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) * Update save_file.py * Update save_file.py * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes * Overwrite existing file if append mode is true * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) * Update test_mcp_servers_file.py * [autofix.ci] apply automated fixes * Update save_file.py * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent d0f95fd commit c74502e

9 files changed

Lines changed: 261 additions & 42 deletions

File tree

src/backend/base/langflow/api/v2/files.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,15 @@ async def fetch_file_object(file_id: uuid.UUID, current_user: CurrentActiveUser,
6969
return file
7070

7171

72-
async def save_file_routine(file, storage_service, current_user: CurrentActiveUser, file_content=None, file_name=None):
72+
async def save_file_routine(
73+
file,
74+
storage_service,
75+
current_user: CurrentActiveUser,
76+
file_content=None,
77+
file_name=None,
78+
*,
79+
append: bool = False,
80+
):
7381
"""Routine to save the file content to the storage service."""
7482
file_id = uuid.uuid4()
7583

@@ -79,7 +87,7 @@ async def save_file_routine(file, storage_service, current_user: CurrentActiveUs
7987
file_name = file.filename
8088

8189
# Save the file using the storage service.
82-
await storage_service.save_file(flow_id=str(current_user.id), file_name=file_name, data=file_content)
90+
await storage_service.save_file(flow_id=str(current_user.id), file_name=file_name, data=file_content, append=append)
8391

8492
return file_id, file_name
8593

@@ -92,6 +100,8 @@ async def upload_user_file(
92100
current_user: CurrentActiveUser,
93101
storage_service: Annotated[StorageService, Depends(get_storage_service)],
94102
settings_service: Annotated[SettingsService, Depends(get_settings_service)],
103+
*,
104+
append: bool = False,
95105
) -> UploadFileResponse:
96106
"""Upload a file for the current user and track it in the database."""
97107
# Get the max allowed file size from settings (in MB)
@@ -124,12 +134,25 @@ async def upload_user_file(
124134
mcp_file = await get_mcp_file(current_user)
125135
mcp_file_ext = await get_mcp_file(current_user, extension=True)
126136

137+
# Initialize existing_file for append mode
138+
existing_file = None
139+
127140
if new_filename == mcp_file_ext:
128141
# Check if an existing record exists; if so, delete it to replace with the new one
129142
existing_mcp_file = await get_file_by_name(mcp_file, current_user, session)
130143
if existing_mcp_file:
131144
await delete_file(existing_mcp_file.id, current_user, session, storage_service)
132145
unique_filename = new_filename
146+
elif append:
147+
# In append mode, check if file exists and reuse the same filename
148+
existing_file = await get_file_by_name(root_filename, current_user, session)
149+
if existing_file:
150+
# File exists, append to it by reusing the same filename
151+
# Extract the filename from the path
152+
unique_filename = existing_file.path.split("/")[-1] if "/" in existing_file.path else existing_file.path
153+
else:
154+
# File doesn't exist yet, create new one with extension
155+
unique_filename = f"{root_filename}.{file_extension}" if file_extension else root_filename
133156
else:
134157
# For normal files, ensure unique name by appending a count if necessary
135158
stmt = select(UserFile).where(
@@ -156,7 +179,7 @@ async def upload_user_file(
156179
# Read file content and save with unique filename
157180
try:
158181
file_id, stored_file_name = await save_file_routine(
159-
file, storage_service, current_user, file_name=unique_filename
182+
file, storage_service, current_user, file_name=unique_filename, append=append
160183
)
161184
except Exception as e:
162185
raise HTTPException(status_code=500, detail=f"Error saving file: {e}") from e
@@ -167,18 +190,26 @@ async def upload_user_file(
167190
file_name=stored_file_name,
168191
)
169192

170-
# Create a new file record
171-
new_file = UserFile(
172-
id=file_id,
173-
user_id=current_user.id,
174-
name=root_filename,
175-
path=f"{current_user.id}/{stored_file_name}",
176-
size=file_size,
177-
)
178-
session.add(new_file)
193+
# In append mode, update existing file record if it exists
194+
if append and existing_file:
195+
existing_file.size = file_size
196+
session.add(existing_file)
197+
await session.commit()
198+
await session.refresh(existing_file)
199+
new_file = existing_file
200+
else:
201+
# Create a new file record
202+
new_file = UserFile(
203+
id=file_id,
204+
user_id=current_user.id,
205+
name=root_filename,
206+
path=f"{current_user.id}/{stored_file_name}",
207+
size=file_size,
208+
)
209+
session.add(new_file)
179210

180-
await session.commit()
181-
await session.refresh(new_file)
211+
await session.commit()
212+
await session.refresh(new_file)
182213
except Exception as e:
183214
# Optionally, you could also delete the file from disk if the DB insert fails.
184215
raise HTTPException(status_code=500, detail=f"Database error: {e}") from e

src/backend/base/langflow/initial_setup/starter_projects/News Aggregator.json

Lines changed: 21 additions & 2 deletions
Large diffs are not rendered by default.

src/backend/base/langflow/services/storage/local.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ def build_full_path(self, flow_id: str, file_name: str) -> str:
1717
"""Build the full path of a file in the local storage."""
1818
return str(self.data_dir / flow_id / file_name)
1919

20-
async def save_file(self, flow_id: str, file_name: str, data: bytes) -> None:
20+
async def save_file(self, flow_id: str, file_name: str, data: bytes, *, append: bool = False) -> None:
2121
"""Save a file in the local storage.
2222
2323
Args:
2424
flow_id: The identifier for the flow.
2525
file_name: The name of the file to be saved.
2626
data: The byte content of the file.
27+
append: If True, append to existing file instead of overwriting.
2728
2829
Raises:
2930
FileNotFoundError: If the specified flow does not exist.
@@ -35,9 +36,11 @@ async def save_file(self, flow_id: str, file_name: str, data: bytes) -> None:
3536
file_path = folder_path / file_name
3637

3738
try:
38-
async with async_open(str(file_path), "wb") as f:
39+
mode = "ab" if append else "wb"
40+
async with async_open(str(file_path), mode) as f:
3941
await f.write(data)
40-
await logger.ainfo(f"File {file_name} saved successfully in flow {flow_id}.")
42+
action = "appended to" if append else "saved"
43+
await logger.ainfo(f"File {file_name} {action} successfully in flow {flow_id}.")
4144
except Exception:
4245
logger.exception(f"Error saving file {file_name} in flow {flow_id}")
4346
raise

src/backend/base/langflow/services/storage/s3.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,31 @@ def __init__(self, session_service, settings_service) -> None:
1515
self.s3_client = boto3.client("s3")
1616
self.set_ready()
1717

18-
async def save_file(self, folder: str, file_name: str, data) -> None:
18+
async def save_file(self, folder: str, file_name: str, data, *, append: bool = False) -> None:
1919
"""Save a file to the S3 bucket.
2020
2121
Args:
2222
folder: The folder in the bucket to save the file.
2323
file_name: The name of the file to be saved.
2424
data: The byte content of the file.
25+
append: If True, append to existing file instead of overwriting.
2526
2627
Raises:
2728
Exception: If an error occurs during file saving.
2829
"""
2930
try:
31+
if append:
32+
# For S3, we need to retrieve existing content and append
33+
try:
34+
existing_data = await self.get_file(folder, file_name)
35+
data = existing_data + data
36+
except ClientError:
37+
# File doesn't exist, proceed with new data
38+
pass
39+
3040
self.s3_client.put_object(Bucket=self.bucket, Key=f"{folder}/{file_name}", Body=data)
31-
await logger.ainfo(f"File {file_name} saved successfully in folder {folder}.")
41+
action = "appended to" if append else "saved"
42+
await logger.ainfo(f"File {file_name} {action} successfully in folder {folder}.")
3243
except NoCredentialsError:
3344
await logger.aexception("Credentials not available for AWS S3.")
3445
raise

src/backend/base/langflow/services/storage/service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def set_ready(self) -> None:
2929
self.ready = True
3030

3131
@abstractmethod
32-
async def save_file(self, flow_id: str, file_name: str, data) -> None:
32+
async def save_file(self, flow_id: str, file_name: str, data, *, append: bool = False) -> None:
3333
raise NotImplementedError
3434

3535
@abstractmethod

src/backend/tests/unit/api/v2/test_mcp_servers_file.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@ def __init__(self):
1919
# key -> bytes
2020
self._store: dict[str, bytes] = {}
2121

22-
async def save_file(self, flow_id: str, file_name: str, data: bytes):
23-
self._store[f"{flow_id}/{file_name}"] = data
22+
async def save_file(self, flow_id: str, file_name: str, data: bytes, *, append: bool = False):
23+
key = f"{flow_id}/{file_name}"
24+
if append and key in self._store:
25+
self._store[key] += data
26+
else:
27+
self._store[key] = data
2428

2529
async def get_file_size(self, flow_id: str, file_name: str):
2630
return len(self._store.get(f"{flow_id}/{file_name}", b""))

src/backend/tests/unit/components/processing/test_save_file_component.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,34 @@ def test_adjust_path_expands_home(self, component_class):
238238
result = component._adjust_file_path_with_format(input_path, "csv")
239239
assert str(result) == str(expected_path)
240240
assert "~" not in str(result) # Ensure ~ was expanded
241+
242+
def test_append_mode_txt_file(self, component_class):
243+
"""Test append mode for text files."""
244+
mock_file = MagicMock()
245+
mock_parent = MagicMock()
246+
mock_parent.exists.return_value = True
247+
mock_file.parent = mock_parent
248+
mock_file.expanduser.return_value = mock_file
249+
mock_file.exists.return_value = True # File exists for append
250+
251+
with patch("lfx.components.files_and_knowledge.save_file.Path") as mock_path:
252+
mock_path.return_value = mock_file
253+
mock_file.read_text.return_value = "Existing content"
254+
255+
component = component_class()
256+
component.set_attributes(
257+
{
258+
"input_type": "Message",
259+
"message": Message(text="New content"),
260+
"file_format": "txt",
261+
"file_path": "./test_output.txt",
262+
"append_mode": True,
263+
}
264+
)
265+
266+
result = component.save_to_file()
267+
268+
# Should read existing content and append new content
269+
mock_file.read_text.assert_called_with(encoding="utf-8")
270+
mock_file.write_text.assert_called_once_with("Existing content\nNew content", encoding="utf-8")
271+
assert "appended to" in result

src/lfx/src/lfx/_assets/component_index.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)