feat: Unified File Management API (#6100)

* feat: FIrst pass at file management API

* [autofix.ci] apply automated fixes

* Add delete and edit endpoints

* [autofix.ci] apply automated fixes

* Add file size and duplicate name handling

* Ensure the File model has a unique name

* Ensure count is before extension

* [autofix.ci] apply automated fixes

* Add the correct path to the return

* Added function to handle list of paths in File component

* [autofix.ci] apply automated fixes

* Update input_mixin.py

* Refactor to a v2 endpoint

* Add unit tests

* Update test_files.py

* Update frontend.ts

* [autofix.ci] apply automated fixes

* Remove extension from name

* Cast the string type for like

* Update files.py

* Update base.py

* Update base.py

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Lucas Oliveira <lucas.edu.oli@hotmail.com>
This commit is contained in:
Eric Hare 2025-02-06 10:35:24 -08:00 • committed by GitHub
commit 28e07be870
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 635 additions and 15 deletions

View file

@ -0,0 +1,209 @@
import asyncio
import tempfile
from contextlib import suppress
from pathlib import Path
# we need to import tmpdir
import anyio
import pytest
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from langflow.main import create_app
from langflow.services.auth.utils import get_password_hash
from langflow.services.database.models.api_key.model import ApiKey
from langflow.services.database.models.user.model import User, UserRead
from langflow.services.database.utils import session_getter
from langflow.services.deps import get_db_service
from sqlalchemy.orm import selectinload
from sqlmodel import select
from tests.conftest import _delete_transactions_and_vertex_builds
@pytest.fixture(name="files_created_api_key")
async def files_created_api_key(files_client, files_active_user): # noqa: ARG001
hashed = get_password_hash("random_key")
api_key = ApiKey(
name="files_created_api_key",
user_id=files_active_user.id,
api_key="random_key",
hashed_api_key=hashed,
)
db_manager = get_db_service()
async with session_getter(db_manager) as session:
stmt = select(ApiKey).where(ApiKey.api_key == api_key.api_key)
if existing_api_key := (await session.exec(stmt)).first():
yield existing_api_key
return
session.add(api_key)
await session.commit()
await session.refresh(api_key)
yield api_key
# Clean up
await session.delete(api_key)
await session.commit()
@pytest.fixture(name="files_active_user")
async def files_active_user(files_client): # noqa: ARG001
db_manager = get_db_service()
async with db_manager.with_session() as session:
user = User(
username="files_active_user",
password=get_password_hash("testpassword"),
is_active=True,
is_superuser=False,
)
stmt = select(User).where(User.username == user.username)
if active_user := (await session.exec(stmt)).first():
user = active_user
else:
session.add(user)
await session.commit()
await session.refresh(user)
user = UserRead.model_validate(user, from_attributes=True)
yield user
# Clean up
# Now cleanup transactions, vertex_build
async with db_manager.with_session() as session:
user = await session.get(User, user.id, options=[selectinload(User.flows)])
await _delete_transactions_and_vertex_builds(session, user.flows)
await session.delete(user)
await session.commit()
@pytest.fixture
def max_file_size_upload_fixture(monkeypatch):
monkeypatch.setenv("LANGFLOW_MAX_FILE_SIZE_UPLOAD", "1")
yield
monkeypatch.undo()
@pytest.fixture
def max_file_size_upload_10mb_fixture(monkeypatch):
monkeypatch.setenv("LANGFLOW_MAX_FILE_SIZE_UPLOAD", "10")
yield
monkeypatch.undo()
@pytest.fixture(name="files_client")
async def files_client_fixture(
monkeypatch,
request,
):
# Set the database url to a test database
if "noclient" in request.keywords:
yield
else:
def init_app():
db_dir = tempfile.mkdtemp()
db_path = Path(db_dir) / "test.db"
monkeypatch.setenv("LANGFLOW_DATABASE_URL", f"sqlite:///{db_path}")
monkeypatch.setenv("LANGFLOW_AUTO_LOGIN", "false")
from langflow.services.manager import service_manager
service_manager.factories.clear()
service_manager.services.clear() # Clear the services cache
app = create_app()
return app, db_path
app, db_path = await asyncio.to_thread(init_app)
async with (
LifespanManager(app, startup_timeout=None, shutdown_timeout=None) as manager,
AsyncClient(transport=ASGITransport(app=manager.app), base_url="http://testserver/") as client,
):
yield client
# app.dependency_overrides.clear()
monkeypatch.undo()
# clear the temp db
with suppress(FileNotFoundError):
await anyio.Path(db_path).unlink()
async def test_upload_file(files_client, files_created_api_key):
headers = {"x-api-key": files_created_api_key.api_key}
response = await files_client.post(
"api/v2/files",
files={"file": ("test.txt", b"test content")},
headers=headers,
)
assert response.status_code == 201, f"Expected 201, got {response.status_code}: {response.json()}"
response_json = response.json()
assert "id" in response_json
async def test_download_file(files_client, files_created_api_key):
headers = {"x-api-key": files_created_api_key.api_key}
# First upload a file
response = await files_client.post(
"api/v2/files",
files={"file": ("test.txt", b"test content")},
headers=headers,
)
assert response.status_code == 201
upload_response = response.json()
# Then try to download it
response = await files_client.get(f"api/v2/files/{upload_response['id']}", headers=headers)
assert response.status_code == 200
assert response.content == b"test content"
async def test_list_files(files_client, files_created_api_key):
headers = {"x-api-key": files_created_api_key.api_key}
# First upload a file
response = await files_client.post(
"api/v2/files",
files={"file": ("test.txt", b"test content")},
headers=headers,
)
assert response.status_code == 201
# Then list the files
response = await files_client.get("api/v2/files", headers=headers)
assert response.status_code == 200
files = response.json()
assert len(files) == 1
async def test_delete_file(files_client, files_created_api_key):
headers = {"x-api-key": files_created_api_key.api_key}
response = await files_client.post(
"api/v2/files",
files={"file": ("test.txt", b"test content")},
headers=headers,
)
assert response.status_code == 201
upload_response = response.json()
response = await files_client.delete(f"api/v2/files/{upload_response['id']}", headers=headers)
assert response.status_code == 200
assert response.json() == {"message": "File deleted successfully"}
async def test_edit_file(files_client, files_created_api_key):
headers = {"x-api-key": files_created_api_key.api_key}
# First upload a file
response = await files_client.post(
"api/v2/files",
files={"file": ("test.txt", b"test content")},
headers=headers,
)
assert response.status_code == 201
upload_response = response.json()
# Then list the files
response = await files_client.put(f"api/v2/files/{upload_response['id']}?name=potato.txt", headers=headers)
assert response.status_code == 200
file = response.json()
assert file["name"] == "potato.txt"