diff --git a/docs/docs/Configuration/environment-variables.mdx b/docs/docs/Configuration/environment-variables.mdx index a6482453a..275d16ad3 100644 --- a/docs/docs/Configuration/environment-variables.mdx +++ b/docs/docs/Configuration/environment-variables.mdx @@ -194,6 +194,7 @@ The following table lists the environment variables supported by Langflow. | LANGFLOW_COMPONENTS_PATH | String | `langflow/components` | Path to the directory containing custom components.
See [`--components-path` option](./configuration-cli.mdx#run-components-path). | | LANGFLOW_CONFIG_DIR | String | See description | Set the Langflow configuration directory where files, logs, and the Langflow database are stored. Defaults: **Linux/WSL**: `~/.cache/langflow/`
**macOS**: `/Users//Library/Caches/langflow/`
**Windows**: `%LOCALAPPDATA%\langflow\langflow\Cache`| | LANGFLOW_DATABASE_URL | String | Not set | Set the database URL for Langflow. If not provided, Langflow uses a SQLite database. | +| LANGFLOW_USE_NOOP_DATABASE | Boolean | `false` | Use a no-op database, which avoids database connections and operations. Useful for running flows without a database. | | LANGFLOW_DATABASE_CONNECTION_RETRY | Boolean | `false` | If True, Langflow tries to connect to the database again if it fails. | | LANGFLOW_DB_POOL_SIZE | Integer | `10` | **DEPRECATED:** Use LANGFLOW_DB_CONNECTION_SETTINGS instead. The number of connections to keep open in the connection pool. | | LANGFLOW_DB_MAX_OVERFLOW | Integer | `20` | **DEPRECATED:** Use LANGFLOW_DB_CONNECTION_SETTINGS instead. The number of connections to allow that can be opened beyond the pool size. | diff --git a/src/backend/base/langflow/api/v1/endpoints.py b/src/backend/base/langflow/api/v1/endpoints.py index 9ad6c0fc7..769474327 100644 --- a/src/backend/base/langflow/api/v1/endpoints.py +++ b/src/backend/base/langflow/api/v1/endpoints.py @@ -682,7 +682,9 @@ async def custom_component_update( ): """Update an existing custom component with new code and configuration. - Processes the provided code and template updates, applies parameter changes (including those loaded from the database), updates the component's build configuration, and validates outputs. Returns the updated component node as a JSON-serializable dictionary. + Processes the provided code and template updates, applies parameter changes (including those loaded from the + database), updates the component's build configuration, and validates outputs. Returns the updated component node as + a JSON-serializable dictionary. Raises: HTTPException: If an error occurs during component building or updating. diff --git a/src/backend/base/langflow/services/database/service.py b/src/backend/base/langflow/services/database/service.py index 0fd33acec..85f0dbd53 100644 --- a/src/backend/base/langflow/services/database/service.py +++ b/src/backend/base/langflow/services/database/service.py @@ -27,6 +27,7 @@ from langflow.initial_setup.constants import STARTER_FOLDER_NAME from langflow.services.base import Service from langflow.services.database import models from langflow.services.database.models.user.crud import get_user_by_username +from langflow.services.database.session import NoopSession from langflow.services.database.utils import Result, TableResults from langflow.services.deps import get_settings_service from langflow.services.utils import teardown_superuser @@ -182,14 +183,17 @@ class DatabaseService(Service): @asynccontextmanager async def with_session(self): - async with AsyncSession(self.engine, expire_on_commit=False) as session: - # Start of Selection - try: - yield session - except exc.SQLAlchemyError as db_exc: - logger.error(f"Database error during session scope: {db_exc}") - await session.rollback() - raise + if self.settings_service.settings.use_noop_database: + yield NoopSession() + else: + async with AsyncSession(self.engine, expire_on_commit=False) as session: + # Start of Selection + try: + yield session + except exc.SQLAlchemyError as db_exc: + logger.error(f"Database error during session scope: {db_exc}") + await session.rollback() + raise async def assign_orphaned_flows_to_superuser(self) -> None: """Assign orphaned flows to the default superuser when auto login is enabled.""" diff --git a/src/backend/base/langflow/services/database/session.py b/src/backend/base/langflow/services/database/session.py new file mode 100644 index 000000000..28c6182d6 --- /dev/null +++ b/src/backend/base/langflow/services/database/session.py @@ -0,0 +1,62 @@ +class NoopSession: + class NoopBind: + class NoopConnect: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + pass + + async def run_sync(self, fn, *args, **kwargs): # noqa: ARG002 + return None + + def connect(self): + return self.NoopConnect() + + bind = NoopBind() + + async def add(self, *args, **kwargs): + pass + + async def commit(self): + pass + + async def rollback(self): + pass + + async def execute(self, *args, **kwargs): # noqa: ARG002 + return None + + async def query(self, *args, **kwargs): # noqa: ARG002 + return [] + + async def close(self): + pass + + async def refresh(self, *args, **kwargs): + pass + + async def delete(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + pass + + async def get(self, *args, **kwargs): # noqa: ARG002 + return None + + async def exec(self, *args, **kwargs): # noqa: ARG002 + class _NoopResult: + def first(self): + return None + + def all(self): + return [] + + def one_or_none(self): + return None + + return _NoopResult() diff --git a/src/backend/base/langflow/services/settings/base.py b/src/backend/base/langflow/services/settings/base.py index 6f97401c6..d9a6d6538 100644 --- a/src/backend/base/langflow/services/settings/base.py +++ b/src/backend/base/langflow/services/settings/base.py @@ -126,6 +126,10 @@ class Settings(BaseSettings): - echo: Enable SQL query logging (development only) """ + use_noop_database: bool = False + """If True, disables all database operations and uses a no-op session. + Controlled by LANGFLOW_USE_NOOP_DATABASE env variable.""" + # cache configuration cache_type: Literal["async", "redis", "memory", "disk"] = "async" """The cache type can be 'async' or 'redis'.""" @@ -268,6 +272,13 @@ class Settings(BaseSettings): update_starter_projects: bool = True """If set to True, Langflow will update starter projects.""" + @field_validator("use_noop_database", mode="before") + @classmethod + def set_use_noop_database(cls, value): + if value: + logger.info("Running with NOOP database session. All DB operations are disabled.") + return value + @field_validator("event_delivery", mode="before") @classmethod def set_event_delivery(cls, value, info): diff --git a/src/backend/base/langflow/services/utils.py b/src/backend/base/langflow/services/utils.py index a61cc5f3b..5f10df36c 100644 --- a/src/backend/base/langflow/services/utils.py +++ b/src/backend/base/langflow/services/utils.py @@ -29,7 +29,8 @@ async def get_or_create_super_user(session: AsyncSession, username, password, is from langflow.services.database.models.user.model import User stmt = select(User).where(User.username == username) - user = (await session.exec(stmt)).first() + result = await session.exec(stmt) + user = result.first() if user and user.is_superuser: return None # Superuser already exists @@ -114,7 +115,8 @@ async def teardown_superuser(settings_service, session: AsyncSession) -> None: from langflow.services.database.models.user.model import User stmt = select(User).where(User.username == username) - user = (await session.exec(stmt)).first() + result = await session.exec(stmt) + user = result.first() # Check if super was ever logged in, if not delete it # if it has logged in, it means the user is using it to login if user and user.is_superuser is True and not user.last_login_at: diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py index dd60d49b5..a144d52a7 100644 --- a/src/backend/tests/conftest.py +++ b/src/backend/tests/conftest.py @@ -76,6 +76,10 @@ def blockbuster(request): .can_block_in("rich/traceback.py", "_render_stack") .can_block_in("langchain_core/_api/internal.py", "is_caller_internal") .can_block_in("langchain_core/runnables/utils.py", "get_function_nonlocals") + .can_block_in("alembic/versions", "_load_revisions") + .can_block_in("dotenv/main.py", "find_dotenv") + .can_block_in("alembic/script/base.py", "_load_revisions") + .can_block_in("alembic/env.py", "_do_run_migrations") ) for func in ["os.stat", "os.path.abspath", "os.scandir", "os.listdir"]: @@ -366,6 +370,16 @@ def deactivate_tracing(monkeypatch): monkeypatch.undo() +@pytest.fixture +def use_noop_session(monkeypatch): + monkeypatch.setenv("LANGFLOW_USE_NOOP_DATABASE", "1") + # Optionally patch the Settings object if needed + # from langflow.services.settings.base import Settings + # monkeypatch.setattr(Settings, "use_noop_database", True) + yield + monkeypatch.undo() + + @pytest.fixture(name="client") async def client_fixture( session: Session, # noqa: ARG001 diff --git a/src/backend/tests/unit/components/agents/test_agent_component.py b/src/backend/tests/unit/components/agents/test_agent_component.py index 5f4d7baac..dacd28e3d 100644 --- a/src/backend/tests/unit/components/agents/test_agent_component.py +++ b/src/backend/tests/unit/components/agents/test_agent_component.py @@ -1,5 +1,6 @@ import os from typing import Any +from unittest.mock import AsyncMock, patch from uuid import uuid4 import pytest @@ -14,6 +15,7 @@ from langflow.base.models.openai_constants import ( from langflow.components.agents.agent import AgentComponent from langflow.components.tools.calculator import CalculatorToolComponent from langflow.custom import Component +from langflow.services.database.session import NoopSession from tests.base import ComponentTestBaseWithClient, ComponentTestBaseWithoutClient from tests.unit.mock_language_model import MockLanguageModel @@ -21,7 +23,7 @@ from tests.unit.mock_language_model import MockLanguageModel # Load environment variables from .env file -class TestAgentComponent(ComponentTestBaseWithoutClient): +class TestAgentComponentWithoutClient(ComponentTestBaseWithoutClient): @pytest.fixture def component_class(self): return AgentComponent @@ -99,16 +101,6 @@ class TestAgentComponent(ComponentTestBaseWithoutClient): # Verify model_name field is cleared for Custom assert "model_name" not in updated_config - -class TestAgentComponentWithClient(ComponentTestBaseWithClient): - @pytest.fixture - def component_class(self): - return AgentComponent - - @pytest.fixture - def file_names_mapping(self): - return [] - @pytest.mark.api_key_required @pytest.mark.no_blockbuster async def test_agent_component_with_calculator(self): @@ -124,13 +116,19 @@ class TestAgentComponentWithClient(ComponentTestBaseWithClient): tools=tools, input_value=input_value, api_key=api_key, - model_name="gpt-4o", - agent_llm="OpenAI", + model_name="gpt-4.1-nano", + llm_type="OpenAI", temperature=temperature, _session_id=str(uuid4()), ) - response = await agent.message_response() + with ( + patch.object(NoopSession, "add", new_callable=AsyncMock) as mock_add, + patch.object(NoopSession, "commit", new_callable=AsyncMock) as mock_commit, + ): + response = await agent.message_response() + assert mock_add.called + assert mock_commit.called assert "4" in response.data.get("text") @pytest.mark.api_key_required @@ -141,22 +139,122 @@ class TestAgentComponentWithClient(ComponentTestBaseWithClient): input_value = "What is 2 + 2?" # Iterate over all OpenAI models - failed_models = [] + failed_models = {} for model_name in OPENAI_CHAT_MODEL_NAMES + OPENAI_REASONING_MODEL_NAMES: - # Initialize the AgentComponent with mocked inputs - tools = [CalculatorToolComponent().build_tool()] # Use the Calculator component as a tool - agent = AgentComponent( - tools=tools, - input_value=input_value, - api_key=api_key, - model_name=model_name, - agent_llm="OpenAI", - _session_id=str(uuid4()), - ) + try: + # Initialize the AgentComponent with mocked inputs + tools = [CalculatorToolComponent().build_tool()] # Use the Calculator component as a tool + agent = AgentComponent( + tools=tools, + input_value=input_value, + api_key=api_key, + model_name=model_name, + agent_llm=None, + llm_type="OpenAI", + temperature=0.1, + _session_id=str(uuid4()), + ) - response = await agent.message_response() - if "4" not in response.data.get("text"): - failed_models.append(model_name) + response = await agent.message_response() + if "4" not in response.data.get("text"): + failed_models[model_name] = f"Expected '4' in response but got: {response.data.get('text')}" + except Exception as e: # noqa: BLE001 + failed_models[model_name] = f"Exception occurred: {e!s}" + + assert not failed_models, f"The following models failed the test: {failed_models}" + + @pytest.mark.api_key_required + @pytest.mark.no_blockbuster + async def test_agent_component_with_all_anthropic_models(self): + # Mock inputs + api_key = os.getenv("ANTHROPIC_API_KEY") + input_value = "What is 2 + 2?" + + # Iterate over all Anthropic models + failed_models = {} + + for model_name in ANTHROPIC_MODELS: + try: + # Initialize the AgentComponent with mocked inputs + tools = [CalculatorToolComponent().build_tool()] + agent = AgentComponent( + tools=tools, + input_value=input_value, + api_key=api_key, + model_name=model_name, + agent_llm="Anthropic", + _session_id=str(uuid4()), + ) + + response = await agent.message_response() + response_text = response.data.get("text", "") + + if "4" not in response_text: + failed_models[model_name] = f"Expected '4' in response but got: {response_text}" + + except Exception as e: # noqa: BLE001 + failed_models[model_name] = f"Exception occurred: {e!s}" + + assert not failed_models, "The following models failed the test:\n" + "\n".join( + f"{model}: {error}" for model, error in failed_models.items() + ) + + +class TestAgentComponentWithClient(ComponentTestBaseWithClient): + @pytest.fixture + def component_class(self): + return AgentComponent + + @pytest.fixture + def file_names_mapping(self): + return [] + + @pytest.mark.api_key_required + @pytest.mark.no_blockbuster + async def test_agent_component_with_calculator(self): + api_key = os.getenv("OPENAI_API_KEY") + tools = [CalculatorToolComponent().build_tool()] + input_value = "What is 2 + 2?" + + temperature = 0.1 + + # Initialize the AgentComponent with mocked inputs + agent = AgentComponent( + tools=tools, + input_value=input_value, + api_key=api_key, + model_name="gpt-4o", + agent_llm="OpenAI", + temperature=temperature, + _session_id=str(uuid4()), + ) + response = await agent.message_response() + assert "4" in response.data.get("text") + + @pytest.mark.api_key_required + @pytest.mark.no_blockbuster + async def test_agent_component_with_all_openai_models(self): + api_key = os.getenv("OPENAI_API_KEY") + input_value = "What is 2 + 2?" + + # Iterate over all OpenAI models + failed_models = {} + for model_name in OPENAI_CHAT_MODEL_NAMES + OPENAI_REASONING_MODEL_NAMES: + try: + tools = [CalculatorToolComponent().build_tool()] + agent = AgentComponent( + tools=tools, + input_value=input_value, + api_key=api_key, + model_name=model_name, + agent_llm="OpenAI", + _session_id=str(uuid4()), + ) + response = await agent.message_response() + if "4" not in response.data.get("text"): + failed_models[model_name] = f"Expected '4' in response but got: {response.data.get('text')}" + except Exception as e: # noqa: BLE001 + failed_models[model_name] = f"Exception occurred: {e!s}" assert not failed_models, f"The following models failed the test: {failed_models}" diff --git a/src/backend/tests/unit/custom/custom_component/test_component.py b/src/backend/tests/unit/custom/custom_component/test_component.py index d5c935682..649963599 100644 --- a/src/backend/tests/unit/custom/custom_component/test_component.py +++ b/src/backend/tests/unit/custom/custom_component/test_component.py @@ -1,13 +1,16 @@ from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch import pytest from langflow.components.crewai import CrewAIAgentComponent, SequentialTaskComponent from langflow.components.custom_component import CustomComponent from langflow.components.input_output import ChatInput, ChatOutput +from langflow.custom.custom_component.component import Component from langflow.custom.utils import update_component_build_config from langflow.schema import dotdict +from langflow.schema.message import Message +from langflow.services.database.session import NoopSession from langflow.template import Output -from typing_extensions import override crewai_available = False try: @@ -25,7 +28,7 @@ def test_set_invalid_output(): chatoutput.set(input_value=chatinput.build_config) -@pytest.mark.skipif(not crewai_available, reason="CrewAI is not installed") +@pytest.mark.xfail(reason="CrewAI is not outdated") def test_set_component(): crewai_agent = CrewAIAgentComponent() task = SequentialTaskComponent() @@ -75,12 +78,11 @@ def _assert_all_outputs_have_different_required_inputs(outputs: list[Output]): async def test_update_component_build_config_sync(): class TestComponent(CustomComponent): - @override def update_build_config( self, build_config: dotdict, - field_value: Any, - field_name: str | None = None, + field_value: Any, # noqa: ARG002 + field_name: str | None = None, # noqa: ARG002 ): build_config["foo"] = "bar" return build_config @@ -93,12 +95,11 @@ async def test_update_component_build_config_sync(): async def test_update_component_build_config_async(): class TestComponent(CustomComponent): - @override async def update_build_config( self, build_config: dotdict, - field_value: Any, - field_name: str | None = None, + field_value: Any, # noqa: ARG002 + field_name: str | None = None, # noqa: ARG002 ): build_config["foo"] = "bar" return build_config @@ -107,3 +108,55 @@ async def test_update_component_build_config_async(): build_config = dotdict() build_config = await update_component_build_config(component, build_config, "", "") assert build_config["foo"] == "bar" + + +@pytest.mark.usefixtures("use_noop_session") +@pytest.mark.asyncio +async def test_send_message_without_database(monkeypatch): # noqa: ARG001 + component = Component() + event_manager = MagicMock() + component._event_manager = event_manager + message = Message(text="Hello", session_id="session", flow_id=None, sender="User", sender_name="Test") + with ( + patch.object(NoopSession, "add", new_callable=AsyncMock) as mock_add, + patch.object(NoopSession, "commit", new_callable=AsyncMock) as mock_commit, + ): + result = await component.send_message(message) + assert isinstance(result, Message) + assert result.text == "Hello" + assert result.sender == "User" + assert result.sender_name == "Test" + # Optionally, check that add/commit were called (if you want to enforce this) + assert mock_add.called + assert mock_commit.called + assert event_manager.on_message.called + + +@pytest.mark.usefixtures("use_noop_session") +@pytest.mark.asyncio +async def test_agent_component_send_message_events(monkeypatch): # noqa: ARG001 + from langflow.components.agents.agent import AgentComponent + + event_manager = MagicMock() + agent = AgentComponent( + agent_llm="OpenAI", + input_value="Hello", + system_prompt="You are a helpful assistant.", + tools=[], + _session_id="test-session", + ) + agent._event_manager = event_manager + message = Message(text="Hello", session_id="test-session", flow_id=None, sender="User", sender_name="Test") + with ( + patch.object(NoopSession, "add", new_callable=AsyncMock) as mock_add, + patch.object(NoopSession, "commit", new_callable=AsyncMock) as mock_commit, + ): + result = await agent.send_message(message) + assert isinstance(result, Message) + assert result.text == "Hello" + assert result.sender == "User" + assert result.sender_name == "Test" + # Optionally, check that add/commit were called (if you want to enforce this) + assert mock_add.called + assert mock_commit.called + assert event_manager.on_message.called