Merge branch 'main' into worktree-lfx-identity-forwarding-jwt

This commit is contained in:
Jordan Frazier
2026-06-12 13:51:29 -04:00
committed by GitHub
61 changed files with 12054 additions and 3721 deletions

View File

@ -1,268 +0,0 @@
---
description: "Guidelines for backend development in Langflow, focusing on Python components, FastAPI services, and backend testing."
globs:
- "src/backend/**/*.py"
- "tests/**/*.py"
- "Makefile"
- "pyproject.toml"
- "uv.lock"
alwaysApply: false
---
# Backend Development Guidelines
## Purpose
Guidelines for backend development in Langflow, focusing on Python components, FastAPI services, and backend testing.
---
## 1. Backend Environment Setup
### Prerequisites
- **Python Package Manager:** `uv` (>=0.4) for dependency management
- **Database:** SQLite for development, PostgreSQL for production
- **Development Tools:** `make` for build coordination
### Backend Service
```bash
make backend # Start FastAPI backend on port 7860
```
- Auto-reloads on file changes
- Health check: http://localhost:7860/health
- Backend components: `src/backend/base/langflow/`
---
## 2. Component Development
### Component Structure
```
src/backend/base/langflow/components/
├── agents/ # Agent components
├── data/ # Data processing components
├── embeddings/ # Embedding components
├── input_output/ # Input/output components
├── models/ # Language model components
├── processing/ # Text processing components
├── prompts/ # Prompt components
├── tools/ # Tool components
└── vectorstores/ # Vector store components
```
### Adding New Components
1. **Location:** Add to appropriate subdirectory under `src/backend/base/langflow/components/`
2. **Import:** Update `__init__.py` with alphabetical imports:
```python
from .my_component import MyComponent
__all__ = [
"ExistingComponent",
"MyComponent", # Add alphabetically
]
```
3. **Auto-restart:** Backend auto-restarts on save
4. **Browser refresh:** Refresh browser to see component changes
### Component Testing
- **Unit Tests:** `src/backend/tests/unit/components/`
- **Test Structure:** Mirror component directory structure
- **Test Base Classes:** Use `ComponentTestBaseWithClient` or `ComponentTestBaseWithoutClient`
- **Version Testing:** Provide `file_names_mapping` for backward compatibility
### Development Tips
- **Fast iteration:** Edit component in UI first, then save to source
- **Component updates:** Old components show "Updates Available" after backend restart
- **Testing:** Create comprehensive unit tests for all new components
---
## 3. Backend Code Quality
### Formatting (CRITICAL)
```bash
make format_backend # Format Python code
```
**Important:** Run `make format_backend` _early and often_ (ideally before running linting or committing changes). It auto-corrects the majority of style issues, preventing lengthy manual fixes when lint errors surface later.
### Linting
```bash
make lint # Run linting checks
```
### Testing
```bash
make unit_tests # Run backend unit tests
```
### Pre-commit Workflow
1. **Run `make format_backend`** (FIRST - saves time on lint fixes)
2. Run `make lint`
3. Run `make unit_tests`
4. Commit changes
---
## 4. FastAPI Development
### API Structure
```
src/backend/base/langflow/api/
├── v1/ # API version 1
│ ├── chat.py # Chat endpoints
│ ├── flows.py # Flow management
│ ├── users.py # User management
│ └── ...
└── v2/ # API version 2 (future)
```
### Testing APIs
- Use `client` fixture from `conftest.py`
- Test with `logged_in_headers` for authenticated endpoints
- Example:
```python
async def test_flows_endpoint(client, logged_in_headers):
response = await client.post(
"api/v1/flows/",
json=flow_data,
headers=logged_in_headers
)
assert response.status_code == 201
```
---
## 5. Database Development
### Models Location
```
src/backend/base/langflow/services/database/models/
├── api_key/ # API key models
├── flow/ # Flow models
├── folder/ # Folder models
├── user/ # User models
└── ...
```
### Database Testing
- Use in-memory SQLite for tests
- Database tests may fail in batch runs - run individually if needed:
```bash
uv run pytest src/backend/tests/unit/test_database.py
```
---
## 6. Async Development Patterns
### Component Async Methods
```python
async def run(self) -> MessageType:
"""Main component execution method."""
# Use await for async operations
result = await self.async_operation()
return result
async def message_response(self) -> Message:
"""Return a Message object for chat components."""
return Message(
text=self.input_value,
sender=self.sender,
session_id=self.session_id,
)
```
### Background Tasks
```python
import asyncio
async def process_in_background(self):
"""Process items without blocking."""
# Use asyncio.create_task for background work
task = asyncio.create_task(self.heavy_operation())
# Ensure proper cleanup
try:
result = await task
return result
except asyncio.CancelledError:
# Handle cancellation gracefully
await self.cleanup()
raise
```
### Queue Operations
```python
async def queue_processing(self):
"""Non-blocking queue operations."""
queue = asyncio.Queue()
# Non-blocking put
queue.put_nowait(data)
# Timeout-controlled get
try:
result = await asyncio.wait_for(queue.get(), timeout=5.0)
return result
except asyncio.TimeoutError:
# Handle timeout appropriately
raise ComponentError("Processing timeout")
```
---
## 7. Component Integration Testing
### Flow Testing
```python
from tests.unit.build_utils import create_flow, build_flow, get_build_events
async def test_component_in_flow(client, json_flow, logged_in_headers):
"""Test component within a complete flow."""
flow_id = await create_flow(client, json_flow, logged_in_headers)
build_response = await build_flow(client, flow_id, logged_in_headers)
# Validate flow execution
job_id = build_response["job_id"]
events_response = await get_build_events(client, job_id, logged_in_headers)
assert events_response.status_code == 200
```
### External API Testing
```python
@pytest.mark.api_key_required
@pytest.mark.no_blockbuster
async def test_with_real_api(self):
"""Test component with external service."""
api_key = os.getenv("OPENAI_API_KEY")
component = MyComponent(api_key=api_key, model="gpt-4o")
response = await component.run()
assert response is not None
```
---
## 8. Known Backend Issues
### Testing Quirks
- `test_database.py` may fail in batch runs but pass individually
- Use `@pytest.mark.no_blockbuster` to skip blockbuster plugin when needed
- Context variables may not propagate correctly in `asyncio.to_thread` - test both patterns
### File Changes
- Starter project files auto-format after `langflow run`
- These formatting changes can be committed or ignored
---
## Backend Development Checklist
- [ ] Component added to appropriate subdirectory
- [ ] `__init__.py` updated with alphabetical imports
- [ ] Code formatted with `make format_backend` (FIRST)
- [ ] Linting passed with `make lint`
- [ ] Unit tests created and passing with `make unit_tests`
- [ ] Component tested in UI with backend restart + browser refresh
- [ ] Version mapping provided for backward compatibility
- [ ] Async patterns implemented correctly with proper cleanup
- [ ] External API calls use appropriate pytest markers

View File

@ -1,82 +0,0 @@
---
description: "Guide for creating a basic Langflow component, including requirements gathering, component structure, and implementation best practices."
globs:
- "src/backend/**/components/**/*.py"
- "src/backend/base/langflow/components/**/*.py"
alwaysApply: false
---
# Rule: How to Create a Basic Langflow Component
## Purpose
Guide for Creating a Langflow Component
---
### 1. Gather Requirements
Ask the user for:
- **Component Name:** What should the component be called?
- **Description:** What does the component do?
- **Inputs:** What are the required inputs? (e.g., text, dropdown, boolean, etc.)
- **Outputs:** What should the component output? (e.g., a message, a value, etc.)
- **Category:** Which component category should this component be stored under in `langflow/src/backend/base/langflow/components`
### 2. Define the Component
- Inherit from `Component`.
- Set `display_name`, `description`, `icon`.
- Define the `inputs` and `outputs` as lists of input/output field objects (e.g., `DropdownInput`, `MessageTextInput`, `Output`).
- Implement the main logic as a method (e.g., `get_current_date`, `true_response`, etc.).
### 3. Example: Conditional If-Else Component
```python
class ConditionalRouterComponent(Component):
display_name = "If-Else"
description = "Routes an input message to a corresponding output based on text comparison."
icon = "split"
name = "ConditionalRouter"
inputs = [
# Define your inputs here
]
outputs = [
# Define your outputs here
]
# Implement your logic methods here
```
### 4. Example: Current Date Component
```python
class CurrentDateComponent(Component):
display_name = "Current Date"
description = "Returns the current date and time in the selected timezone."
icon = "clock"
name = "CurrentDate"
inputs = [
# Define your inputs here
]
outputs = [
# Define your outputs here
]
# Implement your logic methods here
```
### 5. Best Practices
- Use clear and descriptive names for inputs and outputs.
- Provide helpful `info` for each input to guide users.
- Handle errors gracefully and provide meaningful error messages.
- Use appropriate icons to visually represent the component's function.
- Use a Lucide icon or if you want a custom icon follow the icon rules (`./cursor/rules/icons.mdc`)
---
## Checklist for Creating a Component
- [ ] Ask the user for component name, description, inputs, and outputs.
- [ ] Define the component class with the required fields.
- [ ] Implement the main logic.
- [ ] Add helpful info and error handling.
- [ ] Test the component.

View File

@ -1,374 +0,0 @@
---
description: "Guidelines for frontend development in Langflow, focusing on React/TypeScript UI components, build processes, and frontend testing."
globs:
- "src/frontend/**/*.{ts,tsx,js,jsx}"
- "src/frontend/**/*.{css,scss,json}"
- "src/frontend/package*.json"
- "src/frontend/vite.config.*"
- "src/frontend/tailwind.config.*"
- "src/frontend/tsconfig.json"
alwaysApply: false
---
## 1. Frontend Environment Setup
### Prerequisites
- **Node.js:** v22.12 LTS for JavaScript runtime
- **Package Manager:** npm (v10.9) for dependency management
- **Development Tools:** Vite for build tooling
### Frontend Service
```bash
make frontend # Start Vite dev server on port 3000
```
- Hot-reload enabled for UI changes
- Access at: http://localhost:3000/
- Frontend source: `src/frontend/`
---
## 2. Frontend Structure
### Directory Layout
```
src/frontend/src/
├── components/ # Reusable UI components
├── pages/ # Page-level components
├── icons/ # Component icons and lazy loading
├── stores/ # State management (Zustand)
├── types/ # TypeScript type definitions
├── utils/ # Utility functions
├── hooks/ # Custom React hooks
├── services/ # API service functions
└── assets/ # Static assets
```
### Key Technologies
- **React 18** with TypeScript
- **Vite** for build tooling and dev server
- **Tailwind CSS** for styling
- **Zustand** for state management
- **React Flow** for flow graph visualization
- **Lucide React** for icons
---
## 3. Frontend Code Quality
### Formatting
```bash
make format_frontend # Format TypeScript/JavaScript code
```
### Linting
```bash
make lint # Run ESLint and TypeScript checks
```
### Testing
```bash
make tests_frontend # Run frontend tests (requires additional setup)
```
### Pre-commit Workflow
1. Run `make format_frontend`
2. Run `make lint`
3. Test changes in browser
4. Commit changes
---
## 4. State Management
### Zustand Stores
```typescript
// stores/myStore.ts
import { create } from 'zustand';
interface MyState {
value: string;
setValue: (value: string) => void;
}
export const useMyStore = create<MyState>((set) => ({
value: '',
setValue: (value) => set({ value }),
}));
```
### Using Stores in Components
```typescript
// components/MyComponent.tsx
import { useMyStore } from '@/stores/myStore';
export function MyComponent() {
const { value, setValue } = useMyStore();
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
```
---
## 5. API Integration
### Service Functions
```typescript
// services/api.ts
import { api } from '@/controllers/API';
export async function createFlow(flowData: FlowData) {
const response = await api.post('/flows/', flowData);
return response.data;
}
export async function getFlows() {
const response = await api.get('/flows/');
return response.data;
}
```
### Error Handling
```typescript
// hooks/useApi.ts
import { useState, useCallback } from 'react';
export function useApi<T>(apiFunction: (...args: any[]) => Promise<T>) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const execute = useCallback(async (...args: any[]) => {
try {
setLoading(true);
setError(null);
const result = await apiFunction(...args);
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
throw err;
} finally {
setLoading(false);
}
}, [apiFunction]);
return { execute, loading, error };
}
```
---
## 6. React Flow Integration
### Flow Graph Components
```typescript
// components/FlowGraph.tsx
import ReactFlow, {
Node,
Edge,
Controls,
Background
} from 'reactflow';
interface FlowGraphProps {
nodes: Node[];
edges: Edge[];
onNodesChange: (changes: NodeChange[]) => void;
onEdgesChange: (changes: EdgeChange[]) => void;
}
export function FlowGraph({
nodes,
edges,
onNodesChange,
onEdgesChange
}: FlowGraphProps) {
return (
<div className="w-full h-full">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
fitView
>
<Controls />
<Background />
</ReactFlow>
</div>
);
}
```
### Custom Node Types
```typescript
// components/nodes/ComponentNode.tsx
import { memo } from 'react';
import { Handle, Position } from 'reactflow';
interface ComponentNodeProps {
data: {
label: string;
icon?: string;
};
}
export const ComponentNode = memo(({ data }: ComponentNodeProps) => {
return (
<div className="px-4 py-2 shadow-md rounded-md bg-white border">
<Handle type="target" position={Position.Top} />
<div className="flex items-center">
{data.icon && (
<img src={data.icon} alt="" className="w-4 h-4 mr-2" />
)}
<span className="text-sm">{data.label}</span>
</div>
<Handle type="source" position={Position.Bottom} />
</div>
);
});
```
---
## 7. Styling with Tailwind
### Component Styling
```typescript
// components/Button.tsx
import { cn } from '@/utils/cn';
interface ButtonProps {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
}
export function Button({
variant = 'primary',
size = 'md',
children,
onClick
}: ButtonProps) {
return (
<button
className={cn(
'rounded-md font-medium transition-colors',
{
'bg-blue-600 hover:bg-blue-700 text-white': variant === 'primary',
'bg-gray-200 hover:bg-gray-300 text-gray-900': variant === 'secondary',
'px-2 py-1 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
}
)}
onClick={onClick}
>
{children}
</button>
);
}
```
### Dark Mode Support
```typescript
// hooks/useDarkMode.ts
import { useDarkStore } from '@/stores/darkStore';
export function useDarkMode() {
const { dark, setDark } = useDarkStore();
const toggle = () => setDark(!dark);
return { isDark: dark, toggle };
}
```
---
## 8. Frontend Testing
### Component Testing
```typescript
// __tests__/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from '@/components/Button';
describe('Button', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
```
### Integration Testing
```typescript
// __tests__/FlowEditor.test.tsx
import { render, screen } from '@testing-library/react';
import { FlowEditor } from '@/pages/FlowEditor';
describe('FlowEditor', () => {
it('loads flow data correctly', async () => {
render(<FlowEditor flowId="test-flow-id" />);
// Wait for flow to load
await screen.findByText('Flow loaded');
expect(screen.getByTestId('flow-canvas')).toBeInTheDocument();
});
});
```
---
## 9. Build and Deployment
### Development Build
```bash
make build_frontend # Build frontend static files
```
### Production Build
```bash
cd src/frontend
npm run build # Creates dist/ directory
```
### Build Integration
- Frontend builds to `src/frontend/dist/`
- Backend serves static files from this directory in production
- `make init` includes frontend build step
---
## Frontend Development Checklist
- [ ] Frontend service running with `make frontend`
- [ ] Changes hot-reload correctly in browser
- [ ] State management uses Zustand stores
- [ ] API calls use proper error handling
- [ ] Components styled with Tailwind CSS
- [ ] Dark mode support implemented where needed
- [ ] Code formatted with `make format_frontend`
- [ ] Linting passed with `make lint`
- [ ] Changes tested in both light and dark mode
- [ ] Components styled with Tailwind CSS
- [ ] Dark mode support implemented where needed
- [ ] Code formatted with `make format_frontend`
- [ ] Changes tested in both light and dark mode

View File

@ -1,137 +0,0 @@
---
description: "Rules for creating and implementing component icons in Langflow, covering both backend Python component icon attributes and frontend React icon implementation."
globs:
- "src/frontend/src/icons/**/*"
- "src/frontend/src/icons/lazyIconImports.ts"
- "src/backend/**/*component*.py"
- "src/backend/**/components/**/*.py"
alwaysApply: false
---
# Component Icon Rules
## Purpose
To ensure consistent, clear, and functional icon usage for components, covering both backend (Python) and frontend (React/TypeScript) steps.
---
## 1. Backend (Python) — Setting the Icon Name
- **Where:** In your component class (e.g., in `src/backend/base/langflow/components/vectorstores/astradb.py`)
- **How:**
Set the `icon` attribute to a string matching the icon you want to use.
```python
icon = "AstraDB"
```
- **Tip:**
The string must match the frontend icon mapping exactly (case-sensitive).
---
## 2. Frontend (React/TypeScript) — Adding the Icon
### a. Create the Icon Component
- **Where:**
In a new directory for your icon, e.g., `src/frontend/src/icons/AstraDB/`.
- **How:**
- Add your SVG as a React component, e.g., `AstraSVG` in `AstraDB.jsx`.
```jsx
const AstraSVG = (props) => (
<svg {...props}>
<path
// ...
/>
</svg>
);
```
- Create an `index.tsx` that exports your icon using `forwardRef`:
```tsx
import React, { forwardRef } from "react";
import AstraSVG from "./AstraDB";
export const AstraDBIcon = forwardRef<
SVGSVGElement,
React.PropsWithChildren<{}>
>((props, ref) => {
return <AstraSVG ref={ref} isDark={isDark} {...props} />;
});
```
#### Supporting Light and Dark Mode Icons
- **How:**
- In your SVG component (e.g., `AstraDB.jsx`), use the `isDark` prop to switch colors:
```jsx
const AstraSVG = (props) => (
<svg {...props}>
<path
fill={props.isDark ? "#ffffff" : "#0A0A0A"}
// ...
/>
</svg>
);
```
- The `isDark` prop is passed from the icon wrapper (see above) and should be used to toggle between light and dark color schemes.
- You can use a utility like `stringToBool` to ensure the prop is interpreted correctly.
### b. Add to Lazy Icon Imports
- **Where:**
In `src/frontend/src/icons/lazyIconImports.ts`
- **How:**
Add an entry to the `lazyIconsMapping` object:
```ts
AstraDB: () =>
import("@/icons/AstraDB").then((mod) => ({ default: mod.AstraDBIcon })),
```
- **Tip:**
The key (`AstraDB`) must match the string used in the backend.
---
## 3. Best Practices
- **Naming:**
Use clear, recognizable names (e.g., `"AstraDB"`, `"Postgres"`, `"OpenAI"`).
- **Consistency:**
Always use the same icon name for the same service across backend and frontend.
- **Missing Icon:**
If no icon exists, use a [lucide icon](https://lucide.dev/icons)
- **Light/Dark Mode:**
Always support both light and dark mode for custom icons by using the `isDark` prop in your SVG.
---
## 4. Checklist for Adding a New Icon
- [ ] Decide on a clear, descriptive icon name (e.g., `AstraDB`).
- [ ] In your Python component, set `icon = "YourIconName"`.
- [ ] Create a new icon directory in `src/frontend/src/icons/YourIconName/`.
- [ ] Add your SVG as a React component (e.g., `YourIconNameIcon.jsx`).
- [ ] Create an `index.tsx` that exports your icon using `forwardRef` and passes the `isDark` prop.
- [ ] Add your icon to `lazyIconsMapping` in `src/frontend/src/icons/lazyIconImports.ts` with the exact same name.
- [ ] Verify the icon appears correctly in the UI in both light and dark mode.
- [ ] If no suitable icon exists, use a generic icon and request a new one if needed.
---
**Example for AstraDB:**
- Backend:
```python
icon = "AstraDB"
```
- Frontend:
- `src/icons/AstraDB/AstraDB.jsx` (SVG as React component, uses `isDark` prop)
- `src/icons/AstraDB/index.tsx` (exports `AstraDBIcon` and passes `isDark`)
- Add to `lazyIconImports.ts`:
```ts
AstraDB: () =>
import("@/icons/AstraDB").then((mod) => ({ default: mod.AstraDBIcon })),
```
---

View File

@ -1,561 +0,0 @@
---
description: "Guidelines for testing Langflow components and backend code, with emphasis on async patterns and robust, well-documented testing practices."
globs:
- "src/backend/tests/**/*.py"
- "src/frontend/**/*.test.{ts,tsx,js,jsx}"
- "src/frontend/**/*.spec.{ts,tsx,js,jsx}"
- "tests/**/*.py"
- "conftest.py"
- "pytest.ini"
alwaysApply: false
---
# Testing Guidelines for Langflow
## Purpose
Guidelines for testing Langflow components and backend code, with emphasis on async patterns and robust, well-documented testing practices.
---
## 1. Testing Structure
### Backend Tests Location
- **Unit Tests:** `src/backend/tests/`
- **Component Tests:** `src/backend/tests/unit/components/` (organized by component subdirectory)
- **Integration Tests:** Available via `make integration_tests` (requires additional setup)
### Test File Naming
- Use same filename as component with appropriate test prefix/suffix
- Example: `my_component.py` → `test_my_component.py`
---
## 2. Built-in Fixtures & Base Classes
### `client` Fixture (FastAPI Test Client)
- Defined in `src/backend/tests/conftest.py`
- Provides an **async** `httpx.AsyncClient` connected to the full application via `ASGITransport` + `LifespanManager`.
- Use it for API tests:
```python
async def test_login_endpoint(client):
response = await client.post("api/v1/login", data={"username": "foo", "password": "bar"})
assert response.status_code == 200
```
- Automatically configured with an **in-memory SQLite database** and mocked environment variables. No additional setup needed in individual tests.
- Skip client creation by marking the test with `@pytest.mark.noclient`.
### `ComponentTestBase` Family
Located in `src/backend/tests/base.py`.
| Base Class | Creates `client`? | Typical Use | Notes |
|------------|------------------|-------------|-------|
| `ComponentTestBase` | No | Core logic for component version testing | Requires you to provide fixtures described below. |
| `ComponentTestBaseWithClient` | Yes (`@pytest.mark.usefixtures("client")`) | Components that need API access during `run()` | Inherit when the component interacts with backend services. |
| `ComponentTestBaseWithoutClient` | No | Pure-logic components with no API calls | Lightweight alternative. |
Required fixtures for subclasses:
1. `component_class` → the component **class** under test.
2. `default_kwargs` → dict of kwargs to instantiate the component (can be empty).
3. `file_names_mapping` → list of `VersionComponentMapping` dicts mapping historical **Langflow versions** to module/file names.
Example skeleton:
```python
from tests.base import ComponentTestBaseWithClient, VersionComponentMapping, DID_NOT_EXIST
from langflow.components.my_namespace import MyComponent
class TestMyComponent(ComponentTestBaseWithClient):
@pytest.fixture
def component_class(self):
return MyComponent
@pytest.fixture
def default_kwargs(self):
return {"foo": "bar"}
@pytest.fixture
def file_names_mapping(self):
return [
VersionComponentMapping(version="1.1.1", module="my_module", file_name="my_component.py"),
# Older versions can be mapped or DID_NOT_EXIST
VersionComponentMapping(version="1.0.19", module="my_module", file_name=DID_NOT_EXIST),
]
```
`ComponentTestBase` automatically provides:
- `test_latest_version` → Instantiates component via `component_class` and asserts `run()` doesn't return `None`.
- `test_all_versions_have_a_file_name_defined` → Ensures mapping completeness vs `SUPPORTED_VERSIONS` constant (`src/backend/tests/constants.py`).
- `test_component_versions` (parametrised) → Builds component from source for each supported version and asserts successful execution.
When adding a new component test, **inherit from the correct base class and provide the three fixtures**. This greatly reduces boilerplate and enforces version compatibility.
---
## 3. Component Testing Requirements
### Minimum Testing Requirements
- **Unit Tests:** Create comprehensive unit tests for all new components
- **Manual Test Documentation:** If unit tests are incomplete, create a Markdown file with manual testing steps
- Location: Same directory as unit tests
- Filename: Same as component but with `.md` extension
- Content: Detailed manual testing steps and expected outcomes
### Testing Best Practices
- Test both sync and async code paths
- Mock external dependencies appropriately
- Test error handling and edge cases
- Validate input/output behavior
- Test component initialization and configuration
---
## 4. Async Testing Patterns
### Async Component Testing
```python
import pytest
import asyncio
@pytest.mark.asyncio
async def test_async_component():
# Test async component methods
result = await component.async_method()
assert result is not None
```
### Testing Background Tasks
```python
@pytest.mark.asyncio
async def test_background_task_completion():
# Ensure background tasks complete properly
task = asyncio.create_task(component.background_operation())
result = await asyncio.wait_for(task, timeout=5.0)
assert result.success
```
### Testing Queue Operations
```python
@pytest.mark.asyncio
async def test_queue_operations():
# Test queue put/get operations without blocking
queue = asyncio.Queue()
# Non-blocking put
queue.put_nowait(test_data)
# Verify queue processing
result = await asyncio.wait_for(queue.get(), timeout=1.0)
assert result == test_data
```
---
## 5. Special Testing Considerations
### Blockbuster Plugin
- Use `no_blockbuster` pytest marker to skip blockbuster plugin in tests
- Example: `@pytest.mark.no_blockbuster`
### Database Tests
- `test_database.py` may fail in batch runs but pass individually
- Run problematic tests sequentially: `uv run pytest src/backend/tests/unit/test_database.py`
- Consider this behavior when writing database-related tests
### Context Variables in Async Tests
- Be aware of ContextVar propagation in async tests
- Test both direct event loop execution and `asyncio.to_thread` scenarios
- Ensure proper context isolation between test cases
---
## 6. Test Execution
### Running Tests
```bash
make unit_tests # Run all backend unit tests
make integration_tests # Run integration tests (requires additional setup)
make coverage # Run tests with coverage (requires additional setup)
make tests_frontend # Run frontend tests (requires additional setup)
```
### Individual Test Execution
```bash
# Run specific test file
uv run pytest src/backend/tests/unit/test_specific_component.py
# Run specific test method
uv run pytest src/backend/tests/unit/test_component.py::test_specific_method
# Run with verbose output
uv run pytest -v src/backend/tests/unit/
```
---
## 7. Robust Testing Patterns
### Error Handling Tests
```python
def test_component_error_handling():
"""Test that component handles errors gracefully."""
with pytest.raises(ExpectedError):
component.method_that_should_fail(invalid_input)
# Test error message quality
try:
component.risky_operation()
except ComponentError as e:
assert "helpful error message" in str(e)
```
### Resource Cleanup Tests
```python
@pytest.fixture
async def component_with_cleanup():
"""Fixture ensuring proper resource cleanup."""
component = MyComponent()
await component.initialize()
try:
yield component
finally:
await component.cleanup()
```
### Timeout and Performance Tests
```python
@pytest.mark.asyncio
async def test_operation_timeout():
"""Test that operations respect timeout constraints."""
start_time = time.time()
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(
component.long_running_operation(),
timeout=1.0
)
elapsed = time.time() - start_time
assert elapsed < 1.5 # Allow some tolerance
```
---
## 8. Documentation in Tests
### Test Documentation Standards
- Each test should have a clear docstring explaining its purpose
- Complex test setups should be commented
- Mock usage should be documented
- Expected behaviors should be explicitly stated
### Example Well-Documented Test
```python
async def test_component_background_processing():
"""
Test that component processes items in background without blocking.
This test verifies:
1. Items are added to processing queue immediately
2. Background processing completes successfully
3. No deadlocks occur during shutdown
4. All tasks are properly cleaned up
"""
# Setup component with background processing
component = BackgroundComponent()
await component.start()
try:
# Add items for processing
for i in range(10):
await component.add_item(f"item_{i}")
# Verify items are queued (non-blocking)
assert component.queue_size() == 10
# Wait for processing to complete
await component.wait_for_completion(timeout=5.0)
# Verify all items processed
assert component.processed_count() == 10
finally:
# Ensure clean shutdown
await component.stop()
assert component.is_stopped()
```
---
## Testing Checklist
- [ ] Unit tests created for all new components
- [ ] Async patterns tested appropriately
- [ ] Error handling and edge cases covered
- [ ] Manual testing documentation created (if unit tests incomplete)
- [ ] Background tasks and queues tested for proper cleanup
- [ ] Database tests considered for sequential execution if needed
- [ ] Appropriate pytest markers used (`no_blockbuster`, etc.)
- [ ] Tests are well-documented with clear docstrings
- [ ] Resource cleanup properly handled in fixtures
- [ ] Timeout and performance constraints validated
---
## 9. Langflow-Specific Testing Patterns
### Message Testing
Test Langflow's `Message` objects and chat functionality:
```python
from langflow.schema.message import Message
from langflow.utils.constants import MESSAGE_SENDER_AI, MESSAGE_SENDER_USER
async def test_message_response(self, component_class, default_kwargs):
"""Test Message object creation and properties."""
component = component_class(**default_kwargs)
message = await component.message_response()
assert isinstance(message, Message)
assert message.text == default_kwargs["input_value"]
assert message.sender == default_kwargs["sender"]
assert message.sender_name == default_kwargs["sender_name"]
assert message.session_id == default_kwargs["session_id"]
assert message.files == default_kwargs["files"]
# Test message properties structure
assert message.properties.model_dump() == {
"background_color": default_kwargs["background_color"],
"text_color": default_kwargs["text_color"],
"icon": default_kwargs["chat_icon"],
# ... other expected properties
}
```
### Flow Testing with JSON Data
Use predefined JSON flows and utility functions:
```python
from tests.unit.build_utils import create_flow, build_flow, get_build_events, consume_and_assert_stream
async def test_flow_execution(client, json_memory_chatbot_no_llm, logged_in_headers):
"""Test flow creation, building, and execution."""
# Create flow from JSON data
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
# Start the build process
build_response = await build_flow(client, flow_id, logged_in_headers)
job_id = build_response["job_id"]
# Get and validate event stream
events_response = await get_build_events(client, job_id, logged_in_headers)
assert events_response.status_code == codes.OK
# Process events and validate structure
await consume_and_assert_stream(events_response, job_id)
```
### Component Testing with Real Services
Test components that need external APIs with proper markers:
```python
@pytest.mark.api_key_required
@pytest.mark.no_blockbuster
async def test_component_with_external_api(self):
"""Test component with external API integration."""
api_key = os.getenv("OPENAI_API_KEY")
component = MyAPIComponent(
api_key=api_key,
model_name="gpt-4o",
input_value="test input",
session_id=str(uuid4()),
)
response = await component.message_response()
assert response.data.get("text") is not None
```
### Mocking Language Models
Use `MockLanguageModel` for testing without external APIs:
```python
from tests.unit.mock_language_model import MockLanguageModel
@pytest.fixture
def default_kwargs(self):
return {
"agent_llm": MockLanguageModel(),
"input_value": "test message",
"session_id": str(uuid4()),
# ... other component-specific kwargs
}
```
### File Handling in Tests
Use `anyio` and `aiofiles` for async file operations:
```python
import aiofiles
import anyio
async def test_file_operations(self, component_class, tmp_path):
"""Test component file handling."""
# Create temporary test file
test_file = anyio.Path(tmp_path) / "test.txt"
await test_file.write_text("Test content", encoding="utf-8")
# Test with file input
component = component_class(files=[str(test_file)])
result = await component.run()
# Verify file was processed
assert len(result.files) == 1
assert await test_file.exists()
```
### API Endpoint Testing
Test Langflow's REST API endpoints:
```python
async def test_flows_endpoint(client, logged_in_headers):
"""Test flow creation via API."""
flow_data = {
"name": "Test Flow",
"description": "Test description",
"data": {"nodes": [], "edges": []}
}
response = await client.post(
"api/v1/flows/",
json=flow_data,
headers=logged_in_headers
)
assert response.status_code == codes.CREATED
flow = response.json()
assert flow["name"] == "Test Flow"
assert "id" in flow
```
### Build Config Testing
Test component configuration updates:
```python
async def test_build_config_updates(self, component_class, default_kwargs):
"""Test dynamic build configuration updates."""
component = await self.component_setup(component_class, default_kwargs)
frontend_node = component.to_frontend_node()
build_config = frontend_node["data"]["node"]["template"]
# Test configuration update
component.set(llm_provider="OpenAI")
updated_config = await component.update_build_config(
build_config, "OpenAI", "llm_provider"
)
assert updated_config["llm_provider"]["value"] == "OpenAI"
assert "gpt-4o" in updated_config["model_name"]["options"]
```
### Testing Event Streams
Test real-time event streaming endpoints:
```python
async def consume_and_validate_events(response, expected_job_id):
"""Consume NDJSON event stream and validate structure."""
count = 0
first_event_seen = False
end_event_seen = False
async for line in response.aiter_lines():
if not line:
continue
parsed = json.loads(line)
# Validate job_id in events
if "job_id" in parsed:
assert parsed["job_id"] == expected_job_id
# First event should be vertices_sorted
if not first_event_seen:
assert parsed["event"] == "vertices_sorted"
first_event_seen = True
# Track end event
elif parsed["event"] == "end":
end_event_seen = True
count += 1
assert first_event_seen and end_event_seen
return count
```
### Testing Component Versioning
Test backward compatibility across Langflow versions:
```python
@pytest.fixture
def file_names_mapping(self):
"""Map component files across Langflow versions."""
return [
VersionComponentMapping(
version="1.1.1",
module="inputs",
file_name="chat"
),
VersionComponentMapping(
version="1.1.0",
module="inputs",
file_name="chat"
),
VersionComponentMapping(
version="1.0.19",
module="inputs",
file_name="ChatInput"
),
]
```
### Webhook Testing
Test webhook endpoints with proper payload handling:
```python
async def test_webhook_endpoint(client, added_webhook_test):
"""Test webhook flow execution."""
endpoint_name = added_webhook_test["endpoint_name"]
endpoint = f"api/v1/webhook/{endpoint_name}"
payload = {"path": "/tmp/test_file.txt"}
response = await client.post(endpoint, json=payload)
assert response.status_code == 202
# Verify webhook processed the payload
# ... additional validation logic
```
### Monkeypatch for Testing Errors
Test error handling by mocking internal functions:
```python
async def test_cancellation_error(client, flow_id, logged_in_headers, monkeypatch):
"""Test error handling during flow cancellation."""
import langflow.api.v1.chat
async def mock_cancel_with_error(*args, **kwargs):
raise RuntimeError("Cancellation failed")
monkeypatch.setattr(
langflow.api.v1.chat,
"cancel_flow_build",
mock_cancel_with_error
)
response = await client.post(
f"api/v1/build/{job_id}/cancel",
headers=logged_in_headers
)
assert response.status_code == codes.INTERNAL_SERVER_ERROR
assert "Cancellation failed" in response.json()["detail"]
```

View File

@ -117,8 +117,11 @@ jobs:
TODAY=$(date -u +"%Y-%m-%d")
echo "Today's date: $TODAY"
# Query PyPI API for the langflow package
HTTP_STATUS=$(curl -s -o response.json -w "%{http_code}" https://pypi.org/pypi/langflow-nightly/json)
# Query PyPI for the canonical langflow project. The nightly now publishes as a `.devN`
# pre-release of `langflow` (not a separate `langflow-nightly` distribution), so inspect
# the most recent dev release rather than `.info.version` (which is the latest stable).
# See src/bundles/NIGHTLY.md.
HTTP_STATUS=$(curl -s -o response.json -w "%{http_code}" https://pypi.org/pypi/langflow/json)
# Check HTTP status code first
if [ "$HTTP_STATUS" -ne 200 ]; then
@ -136,16 +139,16 @@ jobs:
exit 0
fi
# Extract the latest version
LATEST_VERSION=$(jq -r '.info.version // empty' response.json)
# Extract the most recent nightly (.devN pre-release), highest by version sort
LATEST_VERSION=$(jq -r '.releases | keys[]' response.json | grep -E '\.dev[0-9]+$' | sort -V | tail -n1)
if [ -z "$LATEST_VERSION" ]; then
echo "Could not extract latest version"
echo "Could not find a nightly (.devN) release"
echo "success=false" >> $GITHUB_OUTPUT
exit 0
fi
# Extract the release date of the latest version
# Extract the release date of that nightly version
RELEASE_DATE=$(jq -r --arg ver "$LATEST_VERSION" '.releases[$ver][0].upload_time_iso_8601 // empty' response.json | cut -d'T' -f1)
if [ -z "$RELEASE_DATE" ]; then

View File

@ -161,12 +161,12 @@ jobs:
NIGHTLY_TAG="${{ inputs.nightly_tag || 'langflowai/langflow-nightly:latest' }}"
echo "Upgrading to nightly version: $NIGHTLY_TAG"
# Remove stable distributions before installing nightly.
# langflow and langflow-nightly share langflow-base which writes to the
# same site-packages/langflow/ namespace. If stable stays installed,
# python -m langflow still boots the stable version and no real
# nightly migration is exercised (false-positive test).
echo "Removing stable langflow to avoid namespace collision with nightly..."
# Remove the stable install first. The nightly now publishes as a `.devN` pre-release of
# the canonical `langflow` (same distribution as stable, different version), so a clean
# uninstall avoids stale files and guarantees the dev version is what boots — otherwise
# `python -m langflow` could keep running the stable version and no real nightly migration
# is exercised (false-positive test).
echo "Removing stable langflow to force a clean install of the nightly dev version..."
uv pip uninstall -y langflow langflow-base || true
# Extract version from Docker tag (format: langflowai/langflow-nightly:v1.10.0.dev20260522)
@ -178,21 +178,30 @@ jobs:
VERSION="${VERSION#v}"
echo "Version for PyPI: $VERSION"
# Scope pre-release resolution to the langflow stack only. uv accepts a
# pre-release for a package when its own requirement carries a pre-release
# marker, but NOT via transitive pins (langflow -> langflow-base -> lfx ->
# langflow-sdk are exact ==devN pins on nightlies), so each lockstep package
# must be requested directly. A global --prerelease=allow is NOT safe here:
# it lets unrelated dependencies resolve to alphas (pydantic 2.14.0a1 broke
# langchain-core imports on the first canonical-prerelease nightly).
# langflow-sdk has its own version line, so an explicit .dev0 floor marks it
# pre-release-eligible while the lfx pin selects the exact version.
if [[ "$VERSION" == "latest" ]]; then
# Install latest nightly from PyPI
uv pip install --upgrade 'langflow-nightly[postgresql]'
# Install latest nightly (canonical pre-release) from PyPI
uv pip install --upgrade 'langflow[postgresql]>=0.0.0.dev0' 'langflow-base>=0.0.0.dev0' 'lfx>=0.0.0.dev0' 'langflow-sdk>=0.0.0.dev0'
else
# Install specific version
uv pip install --upgrade "langflow-nightly[postgresql]==$VERSION"
uv pip install --upgrade "langflow[postgresql]==$VERSION" "langflow-base==$VERSION" "lfx==$VERSION" 'langflow-sdk>=0.0.0.dev0'
fi
else
# Direct version string (strip 'v' prefix if present)
VERSION="${NIGHTLY_TAG#v}"
uv pip install --upgrade "langflow-nightly[postgresql]==$VERSION"
uv pip install --upgrade "langflow[postgresql]==$VERSION" "langflow-base==$VERSION" "lfx==$VERSION" 'langflow-sdk>=0.0.0.dev0'
fi
# Verify upgrade
python -c 'from importlib.metadata import version; print("Upgraded to Langflow Nightly version:", version("langflow-nightly"))'
python -c 'from importlib.metadata import version; print("Upgraded to Langflow Nightly version:", version("langflow"))'
- name: Run migration and verify startup
working-directory: migration-test

View File

@ -72,7 +72,11 @@ jobs:
id: version
run: |
echo "Extracting base version from pyproject.toml"
version=$(uv tree 2>/dev/null | grep 'langflow-base' | awk '{print $3}' | sed 's/^v//' | head -n 1)
# Anchor to the langflow-base workspace-root line (same pattern as the
# langflow extraction below): the unanchored grep used to match a
# dependency line whose version sat in field 3, but uv tree now lists
# the langflow-base root line first, which has no third field.
version=$(uv tree 2>/dev/null | grep -E '^langflow-base[[:space:]]' | cut -d' ' -f2 | sed 's/^v//' | head -n 1)
# Verify nightly tag format
@ -351,7 +355,7 @@ jobs:
id: version
run: |
if [[ "${{ inputs.release_type }}" == "nightly-base" ]]; then
version=$(uv tree 2>/dev/null | grep 'langflow-base' | awk '{print $3}' | sed 's/^v//' | head -n 1)
version=$(uv tree 2>/dev/null | grep -E '^langflow-base[[:space:]]' | cut -d' ' -f2 | sed 's/^v//' | head -n 1)
else
version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//')
fi

View File

@ -172,13 +172,9 @@ jobs:
cd src/lfx && uv lock && cd ../..
git add pyproject.toml src/backend/base/pyproject.toml src/lfx/pyproject.toml src/sdk/pyproject.toml uv.lock
# update_lfx_version.py re-pins each bundle's `lfx` dep to
# lfx-nightly==<dev>, so any modified bundle pyprojects need to
# ride the same commit/tag as the rest of the version bumps.
if compgen -G "src/bundles/*/pyproject.toml" > /dev/null; then
git add src/bundles/*/pyproject.toml
fi
git commit -m "Update version and project name"
# The nightly keeps canonical package names and the stable `lfx-*` bundles
# are not modified (see src/bundles/NIGHTLY.md), so no bundle pyprojects ride this commit.
git commit -m "Update version for nightly"
echo "Tagging main with $RELEASE_TAG"
if ! git tag -a $RELEASE_TAG -m "Langflow nightly $RELEASE_TAG"; then

View File

@ -93,7 +93,7 @@ jobs:
- name: Run unit tests
uses: nick-fields/retry@v3
with:
timeout_minutes: 40
timeout_minutes: 50
max_attempts: 2
command: make unit_tests args="-x -vv --splits ${{ matrix.splitCount }} --group ${{ matrix.group }} --reruns 5 --cov --cov-config=src/backend/.coveragerc --cov-report=xml --cov-report=html"

View File

@ -276,7 +276,7 @@ jobs:
create-release:
name: Create GitHub Release
needs: [release-lfx, build-docker]
needs: [validate-version, release-lfx, build-docker]
if: always() && github.event.inputs.create_github_release == 'true' && needs.release-lfx.result == 'success'
runs-on: ubuntu-latest
steps:
@ -337,6 +337,9 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: lfx-v${{ github.event.inputs.version }}
# Pin the minted tag to the commit this release was built from;
# without it GitHub creates the tag at the default-branch HEAD.
target_commitish: ${{ github.sha }}
name: LFX ${{ github.event.inputs.version }}
body_path: release_notes.md
draft: false

View File

@ -1216,6 +1216,20 @@ jobs:
with:
name: dist-main
path: dist
# The release must attach to the dispatched v-prefixed tag. Passing the
# bare version as `tag` made GitHub mint a new lightweight tag at the
# default-branch HEAD — the wrong commit, still carrying the previous
# version (main only adopts the new version via the post-release
# back-merge). Pre-releases keep their computed tag (e.g. 1.10.0rc1),
# but `commit` pins any newly minted tag to the release commit.
- name: Resolve release commit
id: release_commit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
sha=$(gh api "repos/${{ github.repository }}/commits/${{ inputs.release_tag }}" --jq '.sha')
echo "Release tag '${{ inputs.release_tag }}' resolves to commit $sha"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- name: Create Release
uses: ncipollo/release-action@v1
with:
@ -1224,6 +1238,8 @@ jobs:
draft: false
generateReleaseNotes: true
prerelease: ${{ inputs.pre_release }}
tag: ${{ needs.determine-main-version.outputs.version }}
tag: ${{ inputs.pre_release && needs.determine-main-version.outputs.version || inputs.release_tag }}
name: ${{ needs.determine-main-version.outputs.version }}
commit: ${{ steps.release_commit.outputs.sha }}
allowUpdates: true
updateOnlyUnreleased: false

View File

@ -81,8 +81,8 @@ jobs:
run: |
name=$(grep '^name = "' src/sdk/pyproject.toml | head -n 1 | sed 's/.*"\(.*\)".*/\1/')
version=$(grep '^version = "' src/sdk/pyproject.toml | head -n 1 | sed 's/.*"\(.*\)".*/\1/')
if [ "$name" != "langflow-sdk-nightly" ]; then
echo "Name $name does not match langflow-sdk-nightly. Exiting the workflow."
if [ "$name" != "langflow-sdk" ]; then
echo "Name $name does not match langflow-sdk. Exiting the workflow."
exit 1
fi
if [ "v$version" != "${{ inputs.nightly_tag_sdk }}" ]; then
@ -94,10 +94,11 @@ jobs:
id: verify
run: |
cd src/lfx
name=$(uv tree | grep 'lfx' | head -n 1 | awk '{print $1}')
version=$(uv tree | grep 'lfx' | head -n 1 | awk '{print $2}')
if [ "$name" != "lfx-nightly" ]; then
echo "Name $name does not match lfx-nightly. Exiting the workflow."
# Root the tree at lfx; a bare `grep lfx` matches bundles like `lfx-ibm` first.
name=$(uv tree --package lfx | head -n 1 | awk '{print $1}')
version=$(uv tree --package lfx | head -n 1 | awk '{print $2}')
if [ "$name" != "lfx" ]; then
echo "Name $name does not match lfx. Exiting the workflow."
exit 1
fi
if [ "$version" != "${{ inputs.nightly_tag_lfx }}" ]; then
@ -188,12 +189,14 @@ jobs:
- name: Verify Nightly Name and Version
id: verify
run: |
name=$(uv tree | grep 'langflow-base' | awk '{print $2}' | head -n 1)
version=$(uv tree | grep 'langflow-base' | awk '{print $3}' | head -n 1)
# Strip extras from package name (e.g., "langflow-base-nightly[complete]" -> "langflow-base-nightly")
# Root the tree at langflow-base; it prints as a top-level `langflow-base v<ver>` line,
# so a bare `grep langflow-base` would read the version into $name and leave $version empty.
name=$(uv tree --package langflow-base | head -n 1 | awk '{print $1}')
version=$(uv tree --package langflow-base | head -n 1 | awk '{print $2}')
# Strip extras from package name (e.g., "langflow-base[complete]" -> "langflow-base")
name_without_extras=$(echo $name | sed 's/\[.*\]//')
if [ "$name_without_extras" != "langflow-base-nightly" ]; then
echo "Name $name_without_extras does not match langflow-base-nightly. Exiting the workflow."
if [ "$name_without_extras" != "langflow-base" ]; then
echo "Name $name_without_extras does not match langflow-base. Exiting the workflow."
echo "skipped=true" >> $GITHUB_OUTPUT
exit 1
fi
@ -297,10 +300,10 @@ jobs:
- name: Verify Nightly Name and Version
id: verify
run: |
name=$(uv tree | grep -E '^langflow(-nightly)?[[:space:]]' | awk '{print $1}')
version=$(uv tree | grep -E '^langflow(-nightly)?[[:space:]]' | awk '{print $2}')
if [ "$name" != "langflow-nightly" ]; then
echo "Name $name does not match langflow-nightly. Exiting the workflow."
name=$(uv tree | grep -E '^langflow[[:space:]]' | awk '{print $1}')
version=$(uv tree | grep -E '^langflow[[:space:]]' | awk '{print $2}')
if [ "$name" != "langflow" ]; then
echo "Name $name does not match langflow. Exiting the workflow."
exit 1
fi
if [ "$version" != "${{ inputs.nightly_tag_release }}" ]; then
@ -337,9 +340,9 @@ jobs:
- name: Prepare Langflow Main artifact
run: |
shopt -s nullglob
MAIN_WHEELS=(dist/langflow_nightly-*.whl)
MAIN_WHEELS=(dist/langflow-*.whl)
if [ ${#MAIN_WHEELS[@]} -ne 1 ]; then
echo "Expected exactly one langflow-nightly wheel in dist/, found ${#MAIN_WHEELS[@]}"
echo "Expected exactly one langflow wheel in dist/, found ${#MAIN_WHEELS[@]}"
ls -la dist/
exit 1
fi
@ -347,23 +350,9 @@ jobs:
mkdir -p main-dist
cp "${MAIN_WHEELS[0]}" main-dist/
- name: Build bundle wheels for cross-platform test
run: |
shopt -s nullglob
bundles=(src/bundles/*/pyproject.toml)
if [ ${#bundles[@]} -eq 0 ]; then
echo "No bundles found under src/bundles/*/"
exit 1
fi
rm -rf bundles-dist
mkdir -p bundles-dist
BUNDLES_DIST="$PWD/bundles-dist"
for bundle_pyproject in "${bundles[@]}"; do
bundle_dir=$(dirname "$bundle_pyproject")
echo "Building wheel for $bundle_dir"
(cd "$bundle_dir" && uv build --wheel --out-dir "$BUNDLES_DIST")
done
ls -la bundles-dist/
# Bundles are NOT rebuilt/republished for nightly. The stable `lfx-*` bundles on
# PyPI work as-is against the canonical `lfx` pre-release (see src/bundles/NIGHTLY.md). The
# cross-platform test self-builds the bundles from src/bundles for install coverage.
# PyPI publishing moved to after cross-platform testing
@ -373,12 +362,6 @@ jobs:
name: dist-nightly-main
path: main-dist
- name: Upload bundles artifact
uses: actions/upload-artifact@v6
with:
name: dist-nightly-bundles
path: bundles-dist
test-cross-platform:
name: Test Cross-Platform Installation
needs: [build-nightly-lfx, build-nightly-base, build-nightly-main]
@ -388,7 +371,6 @@ jobs:
main-artifact-name: "dist-nightly-main"
lfx-artifact-name: "dist-nightly-lfx"
sdk-artifact-name: "dist-nightly-sdk"
bundles-artifact-name: "dist-nightly-bundles"
publish-nightly-lfx:
name: Publish LFX Nightly to PyPI
@ -475,60 +457,10 @@ jobs:
run: |
make publish base=true
publish-nightly-bundles:
name: Publish Langflow Bundles Nightly to PyPI
# langflow-nightly pins each `lfx-<name>-nightly==<dev>` exactly, so the
# bundles must be on PyPI before main is published (publish-nightly-main
# gates on this job). They depend on `lfx-nightly`, so gate on the lfx
# publish too (or skip it when lfx isn't being rebuilt).
needs: [build-nightly-main, test-cross-platform, publish-nightly-lfx]
if: ${{ always() && needs.build-nightly-main.result == 'success' && needs.test-cross-platform.result == 'success' && (needs.publish-nightly-lfx.result == 'success' || inputs.build_lfx == false) }}
runs-on: ubuntu-latest
steps:
- name: Download bundles artifact
uses: actions/download-artifact@v7
with:
name: dist-nightly-bundles
path: bundles-dist
- name: Setup Environment
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
python-version: "3.13"
- name: Publish bundles to PyPI
env:
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: |
shopt -s nullglob
wheels=(bundles-dist/*.whl)
if [ ${#wheels[@]} -eq 0 ]; then
echo "No bundle wheels to publish."
exit 1
fi
failed=0
for wheel in "${wheels[@]}"; do
echo "Publishing $wheel"
# Capture stderr so a re-run without a version bump is a no-op for
# an already-published bundle instead of failing the whole job.
if ! err=$(uv publish "$wheel" 2>&1); then
echo "$err"
if echo "$err" | grep -qiE 'already exists|file already exists|HTTP 400|duplicate'; then
echo "Skipping $wheel: already published."
else
echo "Publish failed for $wheel"
failed=1
fi
fi
done
if [ "$failed" = "1" ]; then
exit 1
fi
publish-nightly-main:
name: Publish Langflow Main Nightly to PyPI
needs: [build-nightly-main, test-cross-platform, publish-nightly-base, publish-nightly-bundles]
if: ${{ always() && needs.build-nightly-main.result == 'success' && needs.test-cross-platform.result == 'success' && needs.publish-nightly-base.result == 'success' && needs.publish-nightly-bundles.result == 'success' }}
needs: [build-nightly-main, test-cross-platform, publish-nightly-base]
if: ${{ always() && needs.build-nightly-main.result == 'success' && needs.test-cross-platform.result == 'success' && needs.publish-nightly-base.result == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code

View File

@ -133,7 +133,7 @@
"filename": ".github/workflows/nightly_build.yml",
"hashed_secret": "3e26d6750975d678acb8fa35a0f69237881576b0",
"is_verified": false,
"line_number": 304,
"line_number": 308,
"is_secret": false
}
],
@ -153,7 +153,7 @@
"filename": ".github/workflows/release_nightly.yml",
"hashed_secret": "3e26d6750975d678acb8fa35a0f69237881576b0",
"is_verified": false,
"line_number": 565,
"line_number": 497,
"is_secret": false
}
],
@ -2352,29 +2352,6 @@
"line_number": 774
}
],
"src/backend/base/langflow/initial_setup/starter_projects/Pok\u00e9dex Agent.json": [
{
"type": "Hex High Entropy String",
"filename": "src/backend/base/langflow/initial_setup/starter_projects/Pok\u00e9dex Agent.json",
"hashed_secret": "54ed260e3bc31bc77ee06754dff850981d39a66c",
"is_verified": false,
"line_number": 115
},
{
"type": "Hex High Entropy String",
"filename": "src/backend/base/langflow/initial_setup/starter_projects/Pok\u00e9dex Agent.json",
"hashed_secret": "d6e6d7b4b115cd3b9d172623199f8c403055fecc",
"is_verified": false,
"line_number": 394
},
{
"type": "Hex High Entropy String",
"filename": "src/backend/base/langflow/initial_setup/starter_projects/Pok\u00e9dex Agent.json",
"hashed_secret": "ecdbe30d19e36761df3620b37270f23c8e3eaa4c",
"is_verified": false,
"line_number": 774
}
],
"src/backend/base/langflow/initial_setup/starter_projects/Portfolio Website Code Generator.json": [
{
"type": "Hex High Entropy String",
@ -2860,7 +2837,7 @@
"filename": "src/backend/tests/conftest.py",
"hashed_secret": "8bb6118f8fd6935ad0876a3be34a717d32708ffd",
"is_verified": false,
"line_number": 490,
"line_number": 515,
"is_secret": false
},
{
@ -2868,7 +2845,7 @@
"filename": "src/backend/tests/conftest.py",
"hashed_secret": "61fbb5a12cd7b1f1fe1624120089efc0cd299e43",
"is_verified": false,
"line_number": 700,
"line_number": 725,
"is_secret": false
}
],
@ -9310,5 +9287,5 @@
}
]
},
"generated_at": "2026-06-05T18:07:39Z"
"generated_at": "2026-06-10T08:36:23Z"
}

View File

@ -0,0 +1,79 @@
# Anti-Patterns and Scar Tissue
These are battle scars. Every rule here cites a real recurring failure: a commit, a revert, or an explicit project lesson. Read this file when you're about to "just" change something — most of the don't/do rules below started as a one-line "small fix" that broke production.
## Don't / Do (with why)
1. **Don't add or rename a field on a component without regenerating starter projects AND the component index.**
*Why:* commits `1a9f4548c`, `5987421bd`, `bbe1dad7c`, `69d29a4c6`, `67af71450`, `4f9beebc0` are all `fix:` commits cleaning up the same "I added a field and shipped" mistake. CI (`9dad1965c`) now syncs the index on label addition — that's a backstop, not a substitute.
2. **Don't rename a component class, display name, or module path.** If unavoidable, add the new component alongside the old one, set `legacy = True` + `replacement = ["<category>.<NewClassName>"]` on the old class, and update every supported version's `file_names_mapping` in tests.
*Why:* class names key saved flows. There is no class-rename map in `setup.py` — the `type_migrations` map there only handles output type strings (`Data``JSON`, `DataFrame``Table`). See `2a7c56e84` (`IngestionDescriberComponent``FileDescriptionGeneratorComponent`) and `f5a0d52197` (starter project rename) for the cleanup churn. See [CONTRACTS.md](./CONTRACTS.md) for the full list.
3. **Don't mock at a different boundary than production calls.** If production uses `subprocess.Popen`, mock `Popen` — not `run`.
*Why:* `a747135d1`, `bc433c375` — mocked tests passed; production failed. **Better: don't mock at all** — project policy is "Avoid mocking in tests." Use real integrations or `MockLanguageModel` for pure-logic LLM paths.
4. **Don't write a Graph test by poking internals.**
*Why:* the canonical pattern is build → connect via `.set()` → call `async_start` → iterate results → validate. Tests that bypass this don't exercise the graph the way users do.
5. **Don't add a retry, broaden an except, or widen a type to make a symptom go away.**
*Why:* "It is not a fix if you didn't have evidence of the change fixing something." See revert chain `9cdbf1b23`, `35aad2f2e`, `e3b90b7351` — fixes that masked rather than solved, then had to be reverted.
6. **Don't bump a dependency speculatively.** Confirm the conflict reproduces locally and the new version actually resolves it.
*Why:* `cf659f0d0`, `ef6a303406`, `38d142a72`, `9029c4b61` — repeated dep-conflict cleanup.
7. **Don't invent model IDs, MCP tool names, or API surface.** Read the registry, the batch dispatcher, and `models.py` first.
*Why:* `9f1402ed9` (`remove fictional gpt-5.3 ids`), `cecc4a6c2` (`expose layout tool as layout_flow to match batch dispatch`).
8. **Don't use raw `langflow run` / `python` when a `uv` command exists.** Always `uv run`. For lfx tests specifically: `uv sync` inside `src/lfx` (not `src/lfx/src/lfx`) to avoid pulling in langflow.
*Why:* lfx must be testable without langflow installed; mixing them masks dependency leaks.
9. **Don't add a database migration without running `make alembic-upgrade` end to end** and `uv run pytest src/backend/tests/unit/test_database.py` sequentially.
*Why:* `d8b9cc38f`, `c30150a2d`, `6585fe661`. `test_database.py` may pass in batch and fail individually — agents have stopped running it.
10. **Don't claim a race condition is fixed without an integration test that reproduced it.** Agent double-execution and queue leaks keep coming back.
*Why:* `8a993ac90`, `42e84d0fd`, `749865b31`, `f30b291f8`.
11. **Don't add `tool_mode=True` to outputs that confuse agents** (e.g., dataframe surfaces).
*Why:* `cbc54108` (`remove tool_mode=True for as_dataframe`), `35aad2f2e` (revert of the original disable). Output tool exposure is a user contract — don't toggle it back and forth.
12. **Don't widen the scope of a PR.** Closely related follow-ups belong in the existing open PR, not a new stacked PR.
13. **Don't ship without running the tests locally.** Typing the test command in the chat is not running it.
14. **Don't make incremental "wip" commits.** One coherent commit per logical change, after everything is done and tested.
15. **Don't push or open PRs without explicit user confirmation.** Pushing during active CI iteration on an already-open PR is fine; opening a new PR or pushing new branches is not.
## Things that look like a fix but aren't
- Adding `try/except Exception: pass` around a flaky call.
- Adding a `time.sleep` to dodge a race instead of awaiting a condition.
- Bumping a dependency version "just in case."
- Marking a failing test `@pytest.mark.skip` without a linked issue.
- Switching `# type: ignore` instead of fixing the type.
- Reverting symptoms (a UI flag, a tool flag) instead of finding the root.
- "Fixing" a test by changing its assertion to match wrong output.
## Before claiming done
- [ ] `make format_backend` and `make lint` clean.
- [ ] Tests for the changed surface ran and passed locally — not just typed out.
- [ ] If a component field/output changed: starter projects regenerated and `component_index.json` rebuilt.
- [ ] If a class/module renamed: old class kept with `legacy=True` + `replacement=[...]`; `file_names_mapping` updated for every supported version.
- [ ] No incremental "wip" commits. One coherent commit per logical change.
- [ ] No "Generated with Claude Code" / `Co-Authored-By: Claude` trailers.
- [ ] No `--no-verify` unless explicitly authorized.
- [ ] PR not pushed and PR not opened until the user explicitly asks.
- [ ] PR description has no test-plan checklist, no Jira links/IDs.
## Where to look for context — don't invent it
- **Starter projects:** `src/backend/base/langflow/initial_setup/starter_projects/`. Any field/output change must regenerate these.
- **Component index:** `src/lfx/src/lfx/_assets/component_index.json` (rebuilt via the index sync workflow added in `9dad1965c`).
- **Output type migrations:** `src/backend/base/langflow/initial_setup/setup.py` `type_migrations` map — handles renames of *output type strings* (e.g., `Data``JSON`). It does NOT remap component class names; for class renames use `legacy=True` + `replacement=[...]`.
- **Version mapping for component tests:** `src/backend/tests/constants.py::SUPPORTED_VERSIONS` and each test's `file_names_mapping`.
- **Release notes / changelog:** `docs/docs/Support/release-notes/` — check before claiming behavior is "new"; many features predate the current task.
- **MCP tool catalog:** dispatched via the batch tool — names must match (`cecc4a6c2` is the canonical "I guessed the tool name" failure).
- **Models / providers:** real model IDs come from the provider's listing endpoint or our adapter, never from training-data memory (`9f1402ed9`).
- **Migrations:** `src/backend/base/langflow/alembic/versions/` — always read the latest two before adding a new one.

View File

@ -0,0 +1,81 @@
# Architecture Boundaries
Langflow is three Python packages and one frontend, layered with one-way dependencies. Most "off-narrative" code is a boundary violation. Read this before adding a new file.
## Package dependency graph (one-way only)
```
frontend (TS) ──HTTP──▶ langflow (api routers, integrations, distribution)
▼ may import
langflow-base (services, graph, db, alembic)
▼ may import
lfx (executor core, base primitives, components)
▼ may import
langchain-core, pydantic, third-party SDKs
```
### Dependency rules
- **`lfx` MUST NOT import `langflow.*` or `langflow-base.*`.** If lfx code needs a service (auth, db, flow lookup), define an interface inside `lfx` and inject the implementation from `langflow`. The repo currently has ~13 upward `from langflow.*` imports inside `src/lfx/src/lfx/components/...` (auth, db, helpers) — these are **known violations**. Do not add more; prefer fixing them.
- **`langflow-base` MAY import `lfx`.** It MUST NOT import vendor-specific component modules from `langflow.components.<vendor>`.
- **`frontend` talks to `langflow` only via HTTP/WebSocket.** No shared filesystem state.
## "Where does this code go?" decision tree
Walk top-down. Stop at the first match.
1. Is it framework-agnostic flow execution, base component classes, or `Component` primitives?
`src/lfx/src/lfx/` (`base/` for shared primitives, `components/` for built-ins shipped with lfx).
2. Is it a FastAPI route, auth, db model, alembic migration, or a lifecycle-managed singleton?
`src/backend/base/langflow/` (`api/`, `services/X/`, `alembic/versions/`).
3. Is it a vendor integration (OpenAI, Pinecone, Notion, …) — a `Component` subclass that wraps a third-party SDK?
`src/lfx/src/lfx/components/<category>/` and update its `__init__.py` alphabetically. Never rename the class.
4. Is it UI, state, or icons?
`src/frontend/src/`. If it consumes a new API field, also update `src/frontend/src/types/`.
5. Is it CLI behavior for `lfx run` / `lfx serve`?
`src/lfx/src/lfx/cli/`.
6. Is it a SQLAlchemy/SQLModel model change?
`services/database/models/` AND `make alembic-revision message="..."` AND apply with `make alembic-upgrade`.
7. Is it a flow JSON schema change?
→ STOP. Existing saved flows must keep loading. Add a version mapping; do not mutate the existing shape. See [CONTRACTS.md](./CONTRACTS.md).
8. Is it shared by both `lfx` and `langflow-base`?
`src/lfx/src/lfx/base/`, never `langflow/base/`.
## Dependency direction — bad/good examples
- **Bad:** `from langflow.services.deps import session_scope` inside `src/lfx/...`.
**Good:** Define `lfx.interfaces.SessionProvider`, accept it as a constructor arg; `langflow` wires the concrete `session_scope` at startup.
- **Bad:** `from langflow.components.openai import ...` inside `langflow-base` core (`api/`, `services/`, `graph/`).
**Good:** Components are loaded dynamically via the component registry; core code references `Component` only.
- **Bad:** A new `MyHelperService` that's just functions.
**Good:** Utility functions go in `langflow/helpers/` or `lfx/utils/`. A service inherits from `services/base.Service` and is registered via `services/factory.py`.
## API change protocol
- **`api/v1/`** is the live, stable surface (~25 routers). Existing v1 endpoints MUST stay backwards-compatible: only additive fields, never rename or remove.
- **`api/v2/`** is the **active redesign surface** (`files`, `mcp`, `registration`, `workflow`) — both are mounted at runtime in `api/router.py`. v2 is **not** "future"; the old cursor rule was wrong.
- Add a new endpoint to v2 only if it (a) replaces a v1 endpoint with a breaking shape change, or (b) belongs to one of the four v2 domains. Otherwise extend v1 additively.
- A breaking change to a v1 endpoint is forbidden. Add a v2 sibling and leave v1 in place.
## Cross-cutting change protocol
A change that touches a request/response shape MUST update three places in the same PR:
1. The pydantic model in `langflow/api/v{1,2}/schemas.py` (or the route's local schema).
2. The TypeScript type in `src/frontend/src/types/` consumed by the affected page/store. There is no OpenAPI generator — the types are hand-maintained, so the frontend silently breaks at runtime if you skip this.
3. If the field is persisted: a new alembic revision (`make alembic-revision message=...`) AND a flow-JSON version mapping if the shape lives inside saved flows.
If you cannot do all three in one PR, do not start.
## Service vs utility vs component
- **Service** (`services/<name>/`): lifecycle-managed singleton, inherits `services.base.Service`, registered through `services/factory.py`, accessed via `services/deps.py`. Use when the thing has state, startup/shutdown, or shared connections (db, cache, queue).
- **Utility** (`helpers/`, `utils/`, or `lfx/utils/`): pure or near-pure functions. Use when there is no shared state and no lifecycle.
- **Component** (`src/lfx/src/lfx/components/<category>/`): user-visible node in the graph, subclass of `Component`, with `display_name`, `inputs`, `outputs`. Use only when the user must wire it on the canvas. Do not add a Component to expose internal plumbing.
## `lfx/base/` vs `langflow/base/`
Both exist. Both have `agents/`, `data/`, `models/`, `prompts/`. New shared primitives go in **`src/lfx/src/lfx/base/`**. The `langflow/base/` tree is legacy; do not add to it.

138
docs/agents/COMPONENTS.md Normal file
View File

@ -0,0 +1,138 @@
# Component Development
Components are the unit of work in Langflow. They render as nodes on the canvas, persist into flow JSON, and become tools for Agents. Get the contract right and the rest of the system follows; get it wrong and you orphan every saved flow that referenced your component.
Components live in **`src/lfx/src/lfx/components/<category>/`**. The mirror tree under `src/backend/base/langflow/components/` is legacy stubs — do not add files there.
## Before you add a component (decision checklist)
Stop and check, in order. If any answer is "yes," do not create a new component.
1. **Does one already exist?** Search:
```bash
rg -l 'display_name = ".*<keyword>.*"' src/lfx/src/lfx/components/
```
and check the category folder. The Calculator regret (two of them, see `tools/calculator.py` `legacy = True, replacement = ["helpers.CalculatorComponent"]`) is what happens when this step is skipped.
2. **Is this a thin wrapper around an existing tool/SDK that an Agent could call directly via `tool_mode=True`?** If yes, add `tool_mode=True` to the relevant input on the existing component instead of building a new one.
3. **Does it belong in a vendor bundle** (e.g., `openai/`, `anthropic/`, `datastax/`) rather than in generic `tools/` or `helpers/`? Vendor logic goes in vendor folders so bundles can ship/version independently.
4. **Is this an lfx-runtime concern** (graph execution, schema, IO primitives) or a **Langflow-app concern** (auth, DB, tracing)? Components are lfx; never import `langflow.services.*` from a component.
5. **Does an existing base class cover this category?** Use it:
- LLMs → `LCModelComponent` (see `openai/openai_chat_model.py`)
- Tools → `LCToolComponent` (see `tools/calculator.py`)
- Vector stores → `LCVectorStoreComponent` + `@check_cached_vector_store` (see `vectorstores/local_db.py`)
- Chat IO → `ChatComponent` (see `input_output/chat.py`)
## Scope rules (one component, one job)
- One job per component. If the description has " and " in it, split it.
- **Inputs budget:** aim for ≤8 visible inputs; push the rest behind `advanced=True`. If you need a mode switch (Ingest/Retrieve etc.), use `TabInput` + `update_build_config` to hide irrelevant fields — see `vectorstores/local_db.py`.
- **Outputs:** one primary `Output(method=...)` is the default. Add more only if downstream nodes genuinely need different shapes (Message vs DataFrame vs Data). Don't add an output you don't use.
- Mark inputs an Agent should be able to fill with `tool_mode=True` (e.g., `local_db.py`) — this is how a component becomes an agent tool.
## Breaking-change list (locked once shipped)
Saved flows reference components by string identifiers. Once a component is merged, the following are **frozen** — change them and existing user flows break silently:
- The **class name** (already in our rules).
- The **`name = "..."` class attribute**. **This is the flow-JSON identifier**, not the class name. See `openai_chat_model.py`, `chat.py`.
- Each input **`name=...`**. Renaming `input_value` → `prompt` orphans every edge pointing at it.
- Each output **`name=...`** and its declared return type. Downstream nodes match on both.
- **Default values** and default behavior of existing inputs (a flow saved with the default re-loads with the new default).
To remove or rework a component, **do not edit it in place**. Add the replacement under the right category, then on the old one set:
```python
legacy = True
replacement = ["<category>.<NewClassName>"]
```
(see `tools/calculator.py`, `flow_controls/sub_flow.py`). The UI shows "Updates Available" and migrates users; the old class stays importable.
See [CONTRACTS.md](./CONTRACTS.md) for the full user-facing surface this protects.
## Conventions agents miss
These appear all over the codebase but rarely in old docs.
- **`name`** (class attr): flow-JSON identifier. Always set it explicitly; do not rely on `__class__.__name__`. Example: `chat.py` `name = "ChatInput"`.
- **`legacy = True` + `replacement = [...]`**: deprecation pair. Always together. Example: `tools/calculator.py`.
- **`tool_mode=True`** on an input: exposes the component as an Agent tool with that input as the tool argument. Example: `vectorstores/local_db.py`.
- **`real_time_refresh=True` + `update_build_config(self, build_config, value, name)`**: dynamic form. Use for mode switches and dependent-field hide/show. Examples: `openai/openai_chat_model.py`, `vectorstores/local_db.py`.
- **`metadata = {"keywords": [...]}`**: extra search terms for the component picker. Example: `data_source/sql_executor.py`.
- **`minimized = True`**: render collapsed by default. Example: `input_output/chat.py`.
- **`documentation = "https://docs.langflow.org/..."`**: deep-link from the node UI. Example: `input_output/chat.py`.
## Placement rules
- **Vendor-specific code** → vendor folder (`openai/`, `anthropic/`, `datastax/`, `cohere/`, …). Not `tools/`, not `models/`.
- **Generic, vendor-agnostic helpers** → `helpers/`, `processing/`, `logic/`, `flow_controls/`.
- **IO primitives** → `input_output/`.
- Add the import to the category `__init__.py` in **alphabetical order**.
- **Do not invent a new top-level category.** If you think you need one, that is a signal to discuss with the team first — categories are visible in the UI sidebar and are part of the product surface.
## Icons
Every component needs an icon. Use a Lucide icon when one fits; create a custom SVG only for vendor logos.
### Lucide icon (default)
```python
icon = "calculator" # any Lucide icon name, lowercase
```
See https://lucide.dev/icons for the catalog.
### Custom vendor icon
For brand logos you need a frontend SVG component. The Python `icon` string and the frontend mapping key must match **exactly** (case-sensitive).
1. **Python:** set `icon = "AstraDB"` on the component.
2. **Frontend SVG component** at `src/frontend/src/icons/AstraDB/AstraDB.jsx`:
```jsx
const AstraSVG = (props) => (
<svg {...props}>
<path fill={props.isDark ? "#ffffff" : "#0A0A0A"} d="..." />
</svg>
);
```
3. **Wrapper** at `src/frontend/src/icons/AstraDB/index.tsx`:
```tsx
import React, { forwardRef } from "react";
import AstraSVG from "./AstraDB";
export const AstraDBIcon = forwardRef<SVGSVGElement, React.PropsWithChildren<{}>>(
(props, ref) => <AstraSVG ref={ref} isDark={isDark} {...props} />
);
```
4. **Lazy import** in `src/frontend/src/icons/lazyIconImports.ts`:
```ts
AstraDB: () =>
import("@/icons/AstraDB").then((mod) => ({ default: mod.AstraDBIcon })),
```
5. Verify in the UI in **both light and dark mode**.
### Icon checklist
- [ ] Python `icon = "..."` set.
- [ ] If custom: SVG component with `isDark` prop, wrapper with `forwardRef`, entry in `lazyIconImports.ts`.
- [ ] Light and dark mode both verified.
## Component testing
See [TESTING.md](./TESTING.md) for the full testing contract. Quick reference:
- Inherit from `ComponentTestBaseWithClient` (needs API) or `ComponentTestBaseWithoutClient` (pure logic).
- Provide three fixtures: `component_class`, `default_kwargs`, `file_names_mapping`.
- Use `MockLanguageModel` for pure-logic LLM paths; use `@pytest.mark.api_key_required` for real-API tests.
- For graph behavior, use the Graph test pattern: build, `.set()`, `async_start`, validate. Do not poke graph internals.
## Canonical examples
When in doubt, read these files before starting:
- LLM with dynamic provider switching: `src/lfx/src/lfx/components/openai/openai_chat_model.py`
- Tool with `tool_mode` and legacy/replacement: `src/lfx/src/lfx/components/tools/calculator.py`
- Vector store with cache + tab inputs: `src/lfx/src/lfx/components/vectorstores/local_db.py`
- Chat input with `minimized`: `src/lfx/src/lfx/components/input_output/chat.py`
- Component with searchable metadata: `src/lfx/src/lfx/components/data_source/sql_executor.py`
- Legacy + replacement pattern: `src/lfx/src/lfx/components/flow_controls/sub_flow.py`

57
docs/agents/CONTRACTS.md Normal file
View File

@ -0,0 +1,57 @@
# User-Facing Contracts
These are the surfaces users depend on. Breaking them silently is the most common way an agent ships work that "doesn't connect to the story." Read this before changing anything below.
## The contract surface
| # | Contract | Source of truth | Rule |
|---|----------|-----------------|------|
| 1 | Component class `name` attribute | `class XComponent: name = "X"` | NEVER rename. Used to match nodes in saved flow JSON. |
| 2 | Component class identifier (Python class name) | `class XComponent` | NEVER rename. Combined with `name` for resolution. |
| 3 | Component file path + module | `src/lfx/src/lfx/components/<cat>/<file>.py` | Renames are breaking. The supported workflow is to add the new component alongside the old one and set `legacy = True` + `replacement = ["<category>.<NewClassName>"]` on the old class. Update every component's `file_names_mapping` in tests for past `SUPPORTED_VERSIONS` (`src/backend/tests/constants.py`). Regenerate starter projects and rebuild the component index. |
| 4 | Input `name=` and `Output(name=...)` on every component | The component file itself | NEVER rename or remove. Saved flows reference these as keys. Adding new optional inputs is safe; removing or renaming breaks every saved flow that used them. |
| 5 | Output order and `Output.types` | Component `outputs = [...]` list | Reordering changes default selection in old flows. Tightening a type is a breaking change; widening is safe. |
| 6 | Default values for inputs | `default=` / `value=` in input spec | Changing a default silently changes behavior for users who relied on the default. Treat as a breaking change unless the old default was a bug. |
| 7 | Flow JSON schema | `src/backend/base/langflow/initial_setup/starter_projects/*.json` (canonical examples) | Top-level `data.nodes[*].data.node` shape and `data.edges` shape are public. New fields must be optional with defaults. |
| 8 | Public REST API | `docs/docs/API-Reference/api-flows-run.mdx`, `api-build.mdx`, `api-files.mdx`, `api-projects.mdx`, `api-logs.mdx`, `api-monitor.mdx`, `api-users.mdx`. Endpoints with `include_in_schema=False` are internal. | Documented endpoints are stable. `POST /api/v1/run/{flow_id_or_name}` and `POST /api/v1/webhook/{flow_id_or_name}` are user contracts — payload shape and status codes are frozen. |
| 9 | MCP tool exposure | `src/backend/base/langflow/api/v1/mcp.py`, `mcp_projects.py`. Tool-mode toggled by `Output.tool_mode` / outputs named `component_as_tool`. | Removing `tool_mode=True` from an existing component output, or changing its name, breaks every Agent flow using it as a tool. |
| 10 | `Message` / `Data` / `DataFrame` schema | `src/lfx/src/lfx/schema/message.py` (`text`, `sender`, `sender_name`, `session_id`, `flow_id`, `timestamp`, `properties`, `content_blocks`, `category`, `files`, `error`, `edit`, `duration`, `session_metadata`) | Inter-component wire format. Add fields with defaults only. Renaming or retyping any listed field breaks every running flow. |
| 11 | Environment variables | `LANGFLOW_*` (server) defined in `src/backend/base/langflow/services/settings/base.py` and `feature_flags.py`. `LFX_*` (executor) defined in `src/lfx/src/lfx/services/settings/base.py`, except a few read directly via `os.getenv` (e.g., `LFX_DEV` in `src/lfx/src/lfx/interface/components.py`). | Public deployment contract. Renaming requires deprecation cycle reading both names. |
| 12 | Database schema | `src/backend/base/langflow/services/database/models/` + `alembic/versions/`. | NEVER edit a model without `make alembic-revision`. Never edit a past migration. Custom-component Python source is stored in user DBs — refactoring an import path used by `from langflow.X import Y` breaks loading those rows. |
| 13 | Starter project JSON | `src/backend/base/langflow/initial_setup/starter_projects/*.json` | Each references real components by `name`/`type`. Renaming a component, removing an input, or changing an output type breaks loading. After ANY component change, re-load the affected starter project. |
| 14 | Webhook payload shape | `POST /api/v1/webhook/{flow_id_or_name}` | External systems POST here. Response status (202 Accepted) and the `dict` body shape are frozen. |
| 15 | Component index | `src/lfx/src/lfx/_assets/component_index.json` | Generated artifact consumed by the frontend. Any field/output addition requires regeneration; CI enforces this on label add. |
## Before-you-change matrix
| If you are about to... | Check / update |
|---|---|
| Rename a component file | Add the new file alongside; set `legacy = True` + `replacement = [...]` on the old class; update every test's `file_names_mapping`; grep `starter_projects/*.json` for the old type |
| Rename an input `name=` | Don't. Add a new input alongside and deprecate the old via `legacy=True` on the component if necessary; grep `starter_projects/*.json` for the old name |
| Add or rename an output | Regenerate starter projects; rebuild component index |
| Change a default value | Treat as breaking. Grep starter projects + tests for reliance |
| Remove `tool_mode=True` from an output | Search agent flows / starter projects using it as a tool; this is a user-visible regression |
| Add a `Message` field | Must be `Optional` with default; never reorder existing |
| Add a REST endpoint | If user-facing, add to `docs/docs/API-Reference/`; if internal, set `include_in_schema=False` |
| Modify a DB model | `make alembic-revision message="..."`; never edit past revisions |
| Change a `LANGFLOW_*` / `LFX_*` env var | Read old name as fallback for at least one minor version; document in release notes |
| Change a request/response schema | Update pydantic schema + `src/frontend/src/types/` + alembic if persisted, all in one PR |
## Breaking changes that look harmless
These all look like cleanup. They are user-visible regressions.
- **Renaming `input_value``text`** on a component: 100% of saved flows referencing that input lose their wiring.
- **Reordering `outputs = [a, b]` to `[b, a]`**: old flows with the default selection now route from the wrong output.
- **Tightening `Output(types=["Message", "Data"])``["Message"]`**: edges that resolved as `Data` go invalid on load.
- **Changing a `default="gpt-4o-mini"` to `default="gpt-5"`**: silently re-bills users.
- **Moving `langflow/components/foo/bar.py``langflow/components/foo2/bar.py`**: custom components stored in user DBs that `from langflow.components.foo.bar import ...` fail on next load. `file_names_mapping` test passes because it only validates listed historical versions, not user code.
- **Dropping `tool_mode=True` from a vector store's `as_dataframe`**: every Agent that called it as a tool now sees the tool disappear.
- **Adding a required field to `Message`**: every queued message in a running deployment fails to deserialize on next read.
- **Renaming a starter project file**: deep links from docs and tutorials 404.
## Why this matters
Langflow flows are **persisted user artifacts** running in production. The system that loads them is forgiving by design: it tolerates new fields, falls back on missing ones, applies type migrations on load. That tolerance is paid for by every contract in the table above. Break one and the system can no longer route around it — the flow stops working.
Treat this file as a checklist. If your change touches any row, the corresponding rule applies; no exceptions without an explicit deprecation plan.

33
docs/agents/PHILOSOPHY.md Normal file
View File

@ -0,0 +1,33 @@
# Project Philosophy
Read this file before scoping any non-trivial work. Most off-narrative work comes from skipping it.
Langflow is a **visual flow builder first**. Every change should make sense to someone whose only interface is dragging nodes onto a canvas. Use these principles to scope work; if a proposed change fails one of them, stop and reconsider.
## Tenets
1. **Flows are user artifacts, not implementation details.** Flow JSON lives in users' databases and runs in production. Backwards compatibility for components is non-negotiable: never rename a class name, `name` attribute, input name, or output name; never remove an input or output; never tighten an output type; never remove methods from base classes like `LCModelComponent`. To change a component, add the new one alongside, set `legacy=True` + `replacement=[...]` on the old, and update `file_names_mapping` in tests so saved flows still load. See [CONTRACTS.md](./CONTRACTS.md) for the full surface.
2. **Every backend feature must land on the canvas.** If a capability cannot be expressed as a component (or a property of one) that a non-Python user can wire up, it is an SDK feature and belongs in `lfx`, not `langflow-base`. Backend changes that have no visual surface are almost always off-narrative.
3. **Components are the unit of work, not endpoints or services.** Before adding a route, store, or service, ask: which component does this serve? If the answer is "none yet," the route is premature. New REST endpoints without a component or UI consumer are off-narrative.
4. **`lfx` is the runtime, `langflow-base` is the platform, `langflow` is the distribution.** Components and the graph engine live in `lfx` (`src/lfx/src/lfx/components/`). API, auth, persistence, and multi-user concerns live in `langflow-base`. The `langflow` package is the integration that ships everything together. New code goes in the lowest layer that can host it. Boundaries are enforced one-way: `langflow``langflow-base``lfx`. See [ARCHITECTURE.md](./ARCHITECTURE.md).
5. **Don't add a config flag for something a builder-user would set on a node.** If a setting affects flow behavior, it's a component input. If it affects deployment, it's an env var. Hidden global flags that change component semantics break the visible-data-flow contract.
6. **Visible data flow beats clever magic.** Implicit context, hidden globals, and side-channel state make flows unreadable on the canvas. Pass data through inputs and outputs. If you find yourself reaching for a global, you're probably building something that doesn't belong in a node.
7. **Composition over capability.** Prefer adding small, single-purpose components users can wire together over one large component with many modes. The `If-Else` / `Current Date` style (one job, clear name, obvious icon) is the target shape.
8. **Every component needs `display_name`, `description`, `icon`, and a sensible category.** A component that doesn't render legibly in the sidebar shouldn't ship. The icon is part of the API, not decoration — pick a Lucide icon that matches the verb.
9. **The Playground is the test harness builder-users see.** A component is "done" when it works end-to-end in a flow on the canvas, not when its unit test passes. Use the Graph test pattern (build, `.set()`, `async_start`, validate) before claiming a feature works.
10. **Two audiences, one product: visual builders and Python devs.** When their needs conflict, the visual builder wins for `langflow-base` features and the Python dev wins for `lfx` SDK features. Don't compromise the canvas to make the SDK prettier, and don't compromise the SDK to make the canvas easier.
11. **It is not a fix if you didn't have evidence of the change fixing something.** Adding error handling, retries, type widening, or skipping a flaky test does not constitute a fix. Reproduce the failure, demonstrate the fix removes it, then ship. See [ANTI-PATTERNS.md](./ANTI-PATTERNS.md).
## How to apply
When scoping a task, walk through the tenets and name the ones the work serves. If a change fails tenet 1, 2, or 3, stop and surface the conflict before writing code. If you can't tell which tenet a change serves, it's probably off-narrative — ask.

163
docs/agents/TESTING.md Normal file
View File

@ -0,0 +1,163 @@
# Testing
Conventions for backend tests. Frontend testing follows the standard Jest/Playwright patterns documented in `src/frontend/`.
## Project policy: avoid mocking
Prefer real integrations. The pattern of "mocked test passes, production fails" has cost us multiple release cycles — see [ANTI-PATTERNS.md](./ANTI-PATTERNS.md) for the relevant commit history. Use mocks only when:
- The dependency is an LLM and the test exercises pure logic → use `MockLanguageModel` from `tests/unit/mock_language_model.py`.
- The dependency is genuinely unreliable and orthogonal to what's being tested.
Otherwise: hit the real thing, mark the test with `@pytest.mark.api_key_required` if it needs credentials, and let CI gate it.
## Built-in fixtures
### `client` (FastAPI test client)
Defined in `src/backend/tests/conftest.py`. Async `httpx.AsyncClient` connected to the full app via `ASGITransport` + `LifespanManager`. Auto-configured with in-memory SQLite and mocked env vars. Skip with `@pytest.mark.noclient`.
```python
async def test_login_endpoint(client):
response = await client.post("api/v1/login", data={"username": "foo", "password": "bar"})
assert response.status_code == 200
```
For authenticated routes, also use the `logged_in_headers` fixture.
## Component test base classes
Located in `src/backend/tests/base.py`.
| Base class | Creates `client`? | Use for |
|---|---|---|
| `ComponentTestBase` | No | Component version testing core logic |
| `ComponentTestBaseWithClient` | Yes | Components that hit backend services during `run()` |
| `ComponentTestBaseWithoutClient` | No | Pure-logic components |
### Required fixtures
Every subclass provides three fixtures:
1. **`component_class`** — the component class under test.
2. **`default_kwargs`** — dict of kwargs to instantiate the component (can be empty).
3. **`file_names_mapping`** — list of `VersionComponentMapping` entries mapping each historical Langflow version (from `src/backend/tests/constants.py::SUPPORTED_VERSIONS`) to module/file names. Use `DID_NOT_EXIST` for versions before the component was added.
```python
from tests.base import ComponentTestBaseWithClient, VersionComponentMapping, DID_NOT_EXIST
from langflow.components.my_namespace import MyComponent
class TestMyComponent(ComponentTestBaseWithClient):
@pytest.fixture
def component_class(self):
return MyComponent
@pytest.fixture
def default_kwargs(self):
return {"foo": "bar"}
@pytest.fixture
def file_names_mapping(self):
return [
VersionComponentMapping(version="1.1.1", module="my_module", file_name="my_component.py"),
VersionComponentMapping(version="1.0.19", module="my_module", file_name=DID_NOT_EXIST),
]
```
The base class auto-provides:
- `test_latest_version` — instantiates and asserts `run()` doesn't return `None`.
- `test_all_versions_have_a_file_name_defined` — ensures mapping completeness vs `SUPPORTED_VERSIONS`.
- `test_component_versions` (parameterized) — builds the component from source for each supported version and asserts execution.
If you rename or move a component file, you **must** update `file_names_mapping` for every supported version, or saved flows on those versions will fail to load. See [CONTRACTS.md](./CONTRACTS.md) row 3.
## Graph testing pattern
The canonical pattern for tests that exercise the graph engine:
1. Build the graph with connected components.
2. Connect them via `.set()` calls.
3. Call `async_start` and iterate over the results.
4. Validate the results.
Don't poke graph internals. If a test needs to reach into private state, the test is wrong or the API is wrong — fix the right one.
## Async patterns
```python
@pytest.mark.asyncio
async def test_async_component():
result = await component.async_method()
assert result is not None
```
**Awaiting conditions, not sleeping:** never use `time.sleep` or `asyncio.sleep` to mask a race. Wait on a condition (`asyncio.wait_for`, an event, a queue read with timeout). Sleep-based tests are flaky by construction.
## Pytest markers
- `@pytest.mark.api_key_required` — needs an external API key; CI skips when absent.
- `@pytest.mark.no_blockbuster` — skip blockbuster plugin.
- `@pytest.mark.noclient` — skip the `client` fixture.
- `@pytest.mark.asyncio` — async test (also `pytest-asyncio` auto-mode in some configs).
## Database tests
`test_database.py` may fail in batch and pass individually. If you touch DB models or migrations, run it sequentially as part of your verification:
```bash
uv run pytest src/backend/tests/unit/test_database.py
```
Never edit a past alembic migration. Run `make alembic-upgrade` end-to-end before claiming a migration works.
## API endpoint tests
```python
async def test_flows_endpoint(client, logged_in_headers):
flow_data = {"name": "Test", "data": {"nodes": [], "edges": []}}
response = await client.post("api/v1/flows/", json=flow_data, headers=logged_in_headers)
assert response.status_code == 201
```
For event-stream endpoints, consume the NDJSON stream and validate event order:
```python
async for line in response.aiter_lines():
if not line:
continue
parsed = json.loads(line)
# First event should be vertices_sorted; last should be end.
```
## Flow testing with starter JSON
Use `tests/unit/build_utils.py` helpers:
```python
from tests.unit.build_utils import create_flow, build_flow, get_build_events
flow_id = await create_flow(client, json_flow, logged_in_headers)
build_response = await build_flow(client, flow_id, logged_in_headers)
events_response = await get_build_events(client, job_id, logged_in_headers)
```
## Running tests
```bash
make unit_tests # All backend unit tests, parallel
make unit_tests async=false # Sequential
uv run pytest path/to/test.py # Single file
uv run pytest path/to/test.py::test_name # Single test
# lfx tests specifically — must be run after `uv sync` inside src/lfx
cd src/lfx && uv sync && uv run pytest
```
## Verification checklist before claiming "tests pass"
- [ ] You actually ran the command, not just composed it.
- [ ] You ran `test_database.py` sequentially if you touched DB code.
- [ ] You ran the specific test file for any component you changed.
- [ ] No skipped tests without a linked issue.
- [ ] No mocks added at the wrong boundary.

View File

@ -1,70 +1,75 @@
"""Script to generate nightly tags for LFX package."""
"""Generate the nightly tag for the canonical ``lfx`` package.
The nightly publishes ``lfx==<base>.devN`` to the canonical ``lfx`` PyPI project (not a separate
``lfx-nightly`` distribution), so the dev counter is computed against ``lfx``'s own release history.
``<base>`` is the in-development version from
``src/lfx/pyproject.toml`` (the latest ``release-*`` branch the nightly builds from) and ``N`` is
``max(existing same-base devN) + 1``.
Only ``.devN`` releases whose ``base_version`` matches contribute; stable finals (e.g. ``1.10.0``)
are ignored. A 404 (no releases yet) yields ``dev0``; any other lookup failure is fatal (fail
closed) so a transient error cannot regenerate an already-published version. See
``src/bundles/NIGHTLY.md``.
"""
from pathlib import Path
import packaging.version
import requests
import tomllib
from packaging.version import Version
PYPI_LFX_URL = "https://pypi.org/pypi/lfx/json"
PYPI_LFX_NIGHTLY_URL = "https://pypi.org/pypi/lfx-nightly/json"
def get_latest_published_version(*, is_nightly: bool) -> Version:
url = PYPI_LFX_NIGHTLY_URL if is_nightly else PYPI_LFX_URL
res = requests.get(url, timeout=10)
if res.status_code == requests.codes.not_found:
msg = "Package not found on PyPI"
raise requests.RequestException(msg)
try:
version_str = res.json()["info"]["version"]
except (KeyError, ValueError) as e:
msg = "Got unexpected response from PyPI"
raise requests.RequestException(msg) from e
return Version(version_str)
def create_lfx_tag():
# Since LFX has never been released, we'll use the version from pyproject.toml as base
from pathlib import Path
import tomllib
# Read version from pyproject.toml
def _lfx_base_version() -> str:
"""Return the base_version (e.g. "1.11.0") from src/lfx/pyproject.toml."""
lfx_pyproject_path = Path(__file__).parent.parent.parent / "src" / "lfx" / "pyproject.toml"
pyproject_data = tomllib.loads(lfx_pyproject_path.read_text())
return Version(pyproject_data["project"]["version"]).base_version
current_version_str = pyproject_data["project"]["version"]
current_version = Version(current_version_str)
def _dev_numbers(url: str, base_version: str) -> list[int]:
"""Dev numbers of every release at ``url`` whose base_version matches ``base_version``.
A 404 means the package has no releases yet and returns an empty list. Every other failure --
a network error, a non-404 HTTP status, or a malformed 200 -- is fatal and raises, so the
nightly job aborts BEFORE mutating tags rather than regenerating an already-published version.
"""
res = requests.get(url, timeout=10)
if res.status_code == requests.codes.not_found:
return []
res.raise_for_status()
try:
current_nightly_version = get_latest_published_version(is_nightly=True)
nightly_base_version = current_nightly_version.base_version
except (requests.RequestException, KeyError, ValueError):
# If LFX nightly doesn't exist on PyPI yet, this is the first nightly
current_nightly_version = None
nightly_base_version = None
releases = res.json()["releases"]
except (ValueError, KeyError) as e:
msg = f"Unexpected response from {url!r}: missing 'releases' mapping"
raise RuntimeError(msg) from e
build_number = "0"
latest_base_version = current_version.base_version
dev_numbers: list[int] = []
for version_str in releases:
try:
version = Version(version_str)
except packaging.version.InvalidVersion:
continue
if version.base_version == base_version and version.dev is not None:
dev_numbers.append(version.dev)
return dev_numbers
if current_nightly_version and latest_base_version == nightly_base_version:
# If the latest version is the same as the nightly version, increment the build number
build_number = str(current_nightly_version.dev + 1)
new_nightly_version = latest_base_version + ".dev" + build_number
def create_lfx_tag() -> str:
"""Return the next ``lfx`` nightly tag (with a leading ``v``)."""
base_version = _lfx_base_version()
dev_numbers = _dev_numbers(PYPI_LFX_URL, base_version)
next_dev = max(dev_numbers) + 1 if dev_numbers else 0
# Prepend "v" to the version, if DNE.
# This is an update to the nightly version format.
if not new_nightly_version.startswith("v"):
new_nightly_version = "v" + new_nightly_version
new_nightly_version = f"v{base_version}.dev{next_dev}"
# Verify if version is PEP440 compliant.
# Verify the version is PEP 440 compliant.
packaging.version.Version(new_nightly_version)
return new_nightly_version
if __name__ == "__main__":
tag = create_lfx_tag()
print(tag)
print(create_lfx_tag())

View File

@ -1,8 +1,13 @@
#!/usr/bin/env python
"""Idea from https://github.com/streamlit/streamlit/blob/4841cf91f1c820a392441092390c4c04907f9944/scripts/pypi_nightly_create_tag.py.
`langflow-nightly` pins an EXACT dependency on `langflow-base-nightly[complete]==X.Y.Z.devN`.
For the latest published `langflow-nightly` to be installable, the base version it pins must
The nightly is published as canonical `.devN` pre-releases (e.g. `langflow==X.Y.Z.devN`), NOT
separate `*-nightly` distributions, so the dev counter is computed against the canonical
`langflow` / `langflow-base` PyPI histories (their `.devN` pre-releases; stable finals never
contribute). See `src/bundles/NIGHTLY.md`.
`langflow` (the nightly pre-release) pins an EXACT dependency on `langflow-base[complete]==X.Y.Z.devN`.
For the latest published nightly `langflow` to be installable, the base version it pins must
exist on PyPI. The two packages are therefore versioned in lockstep: they share a single dev
number so that, in a single nightly run (publish order base -> main, gated), main's `devN` pin
always references the base `devN` built and published in the same run.
@ -20,11 +25,13 @@ import packaging.version
import requests
from packaging.version import Version
PYPI_LANGFLOW_NIGHTLY_URL = "https://pypi.org/pypi/langflow-nightly/json"
PYPI_LANGFLOW_BASE_NIGHTLY_URL = "https://pypi.org/pypi/langflow-base-nightly/json"
# Count dev releases against the CANONICAL projects (not `*-nightly`), since the nightly is
# published as canonical `.devN` pre-releases of `langflow` / `langflow-base`.
PYPI_LANGFLOW_URL = "https://pypi.org/pypi/langflow/json"
PYPI_LANGFLOW_BASE_URL = "https://pypi.org/pypi/langflow-base/json"
# main and base MUST share one dev number, so the shared number is derived from both packages.
PYPI_NIGHTLY_URLS = (PYPI_LANGFLOW_NIGHTLY_URL, PYPI_LANGFLOW_BASE_NIGHTLY_URL)
PYPI_CANONICAL_URLS = (PYPI_LANGFLOW_URL, PYPI_LANGFLOW_BASE_URL)
ARGUMENT_NUMBER = 2
VALID_BUILD_TYPES = ("main", "base", "both")
@ -80,7 +87,7 @@ def _shared_nightly_version() -> str:
"""Compute the single dev number shared by langflow-nightly and langflow-base-nightly."""
base_version = _root_base_version()
dev_numbers = [dev for url in PYPI_NIGHTLY_URLS for dev in _all_dev_numbers(url, base_version)]
dev_numbers = [dev for url in PYPI_CANONICAL_URLS for dev in _all_dev_numbers(url, base_version)]
# First-ever nightly for this base_version -> dev0. Otherwise max+1, so the result is
# strictly ahead of BOTH packages' newest same-series dev release.

View File

@ -1,63 +1,69 @@
"""Script to generate nightly tags for the SDK package."""
"""Generate the nightly tag for the canonical ``langflow-sdk`` package.
Mirrors ``lfx_nightly_tag.py`` but for the SDK -- the nightly publishes ``langflow-sdk==<base>.devN``
to the canonical ``langflow-sdk`` PyPI project, so the dev counter is computed against that
project's ``.devN`` history (stable finals never contribute). ``<base>`` comes from
``src/sdk/pyproject.toml``. See ``src/bundles/NIGHTLY.md``.
"""
from pathlib import Path
import packaging.version
import requests
import tomllib
from packaging.version import Version
PYPI_SDK_URL = "https://pypi.org/pypi/langflow-sdk/json"
PYPI_SDK_NIGHTLY_URL = "https://pypi.org/pypi/langflow-sdk-nightly/json"
def get_latest_published_version(*, is_nightly: bool) -> Version:
url = PYPI_SDK_NIGHTLY_URL if is_nightly else PYPI_SDK_URL
res = requests.get(url, timeout=10)
if res.status_code == requests.codes.not_found:
msg = "Package not found on PyPI"
raise requests.RequestException(msg)
try:
version_str = res.json()["info"]["version"]
except (KeyError, ValueError) as e:
msg = "Got unexpected response from PyPI"
raise requests.RequestException(msg) from e
return Version(version_str)
def create_sdk_tag():
from pathlib import Path
import tomllib
def _sdk_base_version() -> str:
"""Return the base_version (e.g. "0.1.0") from src/sdk/pyproject.toml."""
sdk_pyproject_path = Path(__file__).parent.parent.parent / "src" / "sdk" / "pyproject.toml"
pyproject_data = tomllib.loads(sdk_pyproject_path.read_text())
return Version(pyproject_data["project"]["version"]).base_version
current_version_str = pyproject_data["project"]["version"]
current_version = Version(current_version_str)
def _dev_numbers(url: str, base_version: str) -> list[int]:
"""Dev numbers of every release at ``url`` whose base_version matches ``base_version``.
A 404 means the package has no releases yet and returns an empty list. Every other failure --
a network error, a non-404 HTTP status, or a malformed 200 -- is fatal and raises, so the
nightly job aborts BEFORE mutating tags rather than regenerating an already-published version.
"""
res = requests.get(url, timeout=10)
if res.status_code == requests.codes.not_found:
return []
res.raise_for_status()
try:
current_nightly_version = get_latest_published_version(is_nightly=True)
nightly_base_version = current_nightly_version.base_version
except (requests.RequestException, KeyError, ValueError):
current_nightly_version = None
nightly_base_version = None
releases = res.json()["releases"]
except (ValueError, KeyError) as e:
msg = f"Unexpected response from {url!r}: missing 'releases' mapping"
raise RuntimeError(msg) from e
build_number = "0"
latest_base_version = current_version.base_version
dev_numbers: list[int] = []
for version_str in releases:
try:
version = Version(version_str)
except packaging.version.InvalidVersion:
continue
if version.base_version == base_version and version.dev is not None:
dev_numbers.append(version.dev)
return dev_numbers
if current_nightly_version and latest_base_version == nightly_base_version:
build_number = str(current_nightly_version.dev + 1)
new_nightly_version = latest_base_version + ".dev" + build_number
def create_sdk_tag() -> str:
"""Return the next ``langflow-sdk`` nightly tag (with a leading ``v``)."""
base_version = _sdk_base_version()
dev_numbers = _dev_numbers(PYPI_SDK_URL, base_version)
next_dev = max(dev_numbers) + 1 if dev_numbers else 0
if not new_nightly_version.startswith("v"):
new_nightly_version = "v" + new_nightly_version
new_nightly_version = f"v{base_version}.dev{next_dev}"
# Verify the version is PEP 440 compliant.
packaging.version.Version(new_nightly_version)
return new_nightly_version
if __name__ == "__main__":
tag = create_sdk_tag()
print(tag)
print(create_sdk_tag())

View File

@ -8,11 +8,18 @@ guaranteed to resolve an lfx new enough to carry it. Before the LFX 0.5.x ->
no upper bound, which silently permitted resolving against the now-dead 0.5.x
line -- and neither pip nor uv flags the cross-line jump.
Pin form: ``lfx>=X.Y.0,<(X+1).0.0`` -- floored at the current minor line,
capped below the next lfx major. The cap is a coarse install-time guard
against an untested lfx major; fine-grained BUNDLE_API compatibility is still
enforced at load time by each ``extension.json``'s ``lfx.compat`` list against
the running lfx's ``BUNDLE_API_VERSION`` (see
Pin form: ``lfx>=X.Y.0.dev0,<(X+1).0.0`` -- floored at the very first
pre-release of the current minor line, capped below the next lfx major. The
``.dev0`` floor (not ``X.Y.0``) is load-bearing: nightlies off a release
branch are canonical ``X.Y.0.devN`` pre-releases, and PEP 440 sorts those
BELOW ``X.Y.0`` -- a plain ``>=X.Y.0`` floor makes the branch's own nightly
``lfx`` unresolvable against its own bundles (langflow-base pins
``lfx==X.Y.0.devN`` exactly, so the resolver cannot back off). ``X.Y.0.dev0``
is the lowest version PEP 440 admits in the line, so every devN / rcN / final
satisfies it while older minor lines stay excluded. The cap is a coarse
install-time guard against an untested lfx major; fine-grained BUNDLE_API
compatibility is still enforced at load time by each ``extension.json``'s
``lfx.compat`` list against the running lfx's ``BUNDLE_API_VERSION`` (see
``src/lfx/src/lfx/extension/manifest.py``).
Idempotent: re-running with the same version is a no-op (so it is safe to call
@ -53,8 +60,10 @@ _VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.\d+")
def lfx_floor_spec(version: str) -> str:
"""Return the bundle ``lfx`` dependency spec for a Langflow/LFX version.
``"1.10.0"`` -> ``"lfx>=1.10.0,<2.0.0"`` (floor at the minor line start,
cap below the next lfx major). A leading ``v`` is tolerated.
``"1.11.0"`` -> ``"lfx>=1.11.0.dev0,<2.0.0"`` (floor at the minor line's
first pre-release so the branch's own ``X.Y.0.devN`` nightlies resolve --
see the module docstring; cap below the next lfx major). A leading ``v``
is tolerated.
NOTE: this floor format is duplicated in ``scripts/migrate/port_bundle.py``
(``_current_lfx_floor``) so each script stays standalone; keep them in step.
@ -64,7 +73,7 @@ def lfx_floor_spec(version: str) -> str:
msg = f"Unparseable version {version!r}; expected X.Y.Z"
raise ValueError(msg)
major, minor = int(match.group(1)), int(match.group(2))
return f"lfx>={major}.{minor}.0,<{major + 1}.0.0"
return f"lfx>={major}.{minor}.0.dev0,<{major + 1}.0.0"
def rewrite_lfx_dep(content: str, floor_spec: str) -> str:

View File

@ -20,8 +20,8 @@ import requests
sys.path.insert(0, str(Path(__file__).parent))
import pypi_nightly_tag as nt
MAIN_URL = nt.PYPI_LANGFLOW_NIGHTLY_URL
BASE_URL = nt.PYPI_LANGFLOW_BASE_NIGHTLY_URL
MAIN_URL = nt.PYPI_LANGFLOW_URL
BASE_URL = nt.PYPI_LANGFLOW_BASE_URL
class _FakeResponse:

View File

@ -16,9 +16,11 @@ def update_base_dep(pyproject_path: str, new_version: str) -> None:
content = filepath.read_text(encoding="utf-8")
# Updated pattern to handle PEP 440 version suffixes, extras (e.g., [complete]),
# both ~= and == version specifiers, and both langflow-base and langflow-base-nightly names
# ~=, ==, and >= version specifiers, and both langflow-base and langflow-base-nightly names
# Captures extras in group 2 to preserve them in the replacement
pattern = re.compile(r'("langflow-base(?:-nightly)?((?:\[[^\]]+\])?)(?:~=|==)[\d.]+(?:\.(?:post|dev|a|b|rc)\d+)*")')
pattern = re.compile(
r'("langflow-base(?:-nightly)?((?:\[[^\]]+\])?)(?:~=|==|>=)[\d.]+(?:\.(?:post|dev|a|b|rc)\d+)*")'
)
# Check if the pattern is found
match = pattern.search(content)
@ -28,7 +30,9 @@ def update_base_dep(pyproject_path: str, new_version: str) -> None:
# Extract extras if present (e.g., "[complete]")
extras = match.group(2) if match.group(2) else ""
replacement = f'"langflow-base-nightly{extras}=={new_version}"'
# Keep the canonical `langflow-base` name; the exact `==<dev>` pin enables pre-release
# resolution down the tree and keeps base in lockstep with the run.
replacement = f'"langflow-base{extras}=={new_version}"'
# Replace the matched pattern with the new one
content = pattern.sub(replacement, content)
@ -43,7 +47,9 @@ def update_lfx_dep_in_base(pyproject_path: str, lfx_version: str) -> None:
# Updated pattern to handle PEP 440 version suffixes, both ~= and == version specifiers,
# and both lfx and lfx-nightly names
pattern = re.compile(r'("lfx(?:-nightly)?(?:~=|==)[\d.]+(?:\.(?:post|dev|a|b|rc)\d+)*")')
replacement = f'"lfx-nightly=={lfx_version}"'
# Pin base's lfx dep to the exact canonical dev version (single `lfx` distribution, no
# `lfx-nightly`), so there is no `lfx` vs `lfx-nightly` install collision with the bundles.
replacement = f'"lfx=={lfx_version}"'
# Check if the pattern is found
if not pattern.search(content):

View File

@ -1,12 +1,19 @@
"""Script to update LFX version for nightly builds."""
"""Update the canonical ``lfx`` package (and its SDK dep) for nightly builds.
The nightly publishes ``lfx`` and ``langflow-sdk`` under their CANONICAL names as ``.devN``
pre-releases -- it does NOT rename them to ``lfx-nightly`` / ``langflow-sdk-nightly``, and it does
NOT give the ``src/bundles/*`` packages their own nightly track. The stable ``lfx-*`` bundles
(pinning ``lfx>=X.Y.0,<(X+1).0.0``) then resolve against the single canonical ``lfx`` distribution,
so there is no ``lfx`` vs ``lfx-nightly`` install collision. See ``src/bundles/NIGHTLY.md``.
This script therefore only (a) sets ``lfx``'s version to the nightly ``.devN`` and (b) re-pins
lfx's ``langflow-sdk`` dependency to the exact canonical dev version.
"""
import re
import sys
from pathlib import Path
import tomllib
from packaging.version import Version
from update_pyproject_name import update_pyproject_name
from update_pyproject_version import update_pyproject_version
# Add the current directory to the path so we can import the other scripts
@ -14,36 +21,20 @@ current_dir = Path(__file__).resolve().parent
sys.path.append(str(current_dir))
BASE_DIR = Path(__file__).parent.parent.parent
ROOT_PYPROJECT = BASE_DIR / "pyproject.toml"
def update_lfx_workspace_dep(pyproject_path: str, new_project_name: str) -> None:
"""Update the LFX workspace dependency in pyproject.toml."""
filepath = BASE_DIR / pyproject_path
content = filepath.read_text(encoding="utf-8")
if new_project_name == "lfx-nightly":
pattern = re.compile(r"lfx = \{ workspace = true \}")
replacement = "lfx-nightly = { workspace = true }"
else:
msg = f"Invalid LFX project name: {new_project_name}"
raise ValueError(msg)
# Updates the dependency name for uv
if not pattern.search(content):
msg = f"lfx workspace dependency not found in {filepath}"
raise ValueError(msg)
content = pattern.sub(replacement, content)
filepath.write_text(content, encoding="utf-8")
def update_sdk_dependency_in_lfx(pyproject_path: str, sdk_version: str) -> None:
"""Update the SDK dependency in the LFX pyproject for nightly builds."""
"""Pin lfx's ``langflow-sdk`` dependency to the exact canonical dev version.
An exact ``==<dev>`` pin keeps the SDK in lockstep with the lfx built in the same run and,
because it names a pre-release explicitly, enables pre-release resolution for ``langflow-sdk``
down the dependency tree without requiring ``--pre``.
"""
filepath = BASE_DIR / pyproject_path
content = filepath.read_text(encoding="utf-8")
pattern = re.compile(r'"langflow-sdk(?:-nightly)?(?:==|~=|>=)[\d.]+(?:\.(?:post|dev|a|b|rc)\d+)*"')
replacement = f'"langflow-sdk-nightly=={sdk_version}"'
replacement = f'"langflow-sdk=={sdk_version}"'
if not pattern.search(content):
msg = f"SDK dependency not found in {filepath}"
@ -53,158 +44,24 @@ def update_sdk_dependency_in_lfx(pyproject_path: str, sdk_version: str) -> None:
filepath.write_text(content, encoding="utf-8")
# Match an `lfx` (or `lfx-nightly`) dependency specifier inside a quoted
# string. The lookahead enforces a version operator immediately after the
# name so we don't accidentally match sibling packages like `lfx-arxiv` or
# `lfx-duckduckgo`.
_BUNDLE_LFX_DEP_PATTERN = re.compile(r'"lfx(?:-nightly)?(?=[<>=!~])[^"]*"')
def update_lfx_dep_in_bundles(lfx_version: str) -> None:
"""Pin every bundle's `lfx` dep to the renamed `lfx-nightly==<version>`.
Each `src/bundles/*/pyproject.toml` floors its `lfx` dep at `>=X.Y`
(no upper bound) against the published `lfx` package. During nightly builds the
workspace `lfx` package gets renamed to `lfx-nightly`, so those pins
no longer resolve against the workspace member — and PyPI may not yet
ship a matching `lfx` either. Rewrite each bundle's pin to
`lfx-nightly==<exact dev version>` so `uv lock` resolves cleanly.
No-op when no bundles exist (e.g. on a branch that hasn't picked up
the bundle extraction) or when a bundle has no `lfx` dep.
"""
bundles_dir = BASE_DIR / "src" / "bundles"
if not bundles_dir.is_dir():
return
replacement = f'"lfx-nightly=={lfx_version}"'
for bundle_pyproject in sorted(bundles_dir.glob("*/pyproject.toml")):
content = bundle_pyproject.read_text(encoding="utf-8")
if not _BUNDLE_LFX_DEP_PATTERN.search(content):
continue
new_content = _BUNDLE_LFX_DEP_PATTERN.sub(replacement, content)
if new_content == content:
continue
bundle_pyproject.write_text(new_content, encoding="utf-8")
print(f"Updated lfx dep in {bundle_pyproject.relative_to(BASE_DIR)} -> lfx-nightly=={lfx_version}")
def _bundle_nightly_version(bundle_base_version: str, lfx_version: str) -> str:
"""Derive a bundle's nightly version: `<bundle base>.dev<N>`.
The dev build number `N` is shared with `lfx-nightly` (the same value the
nightly tagger already pins each bundle's `lfx` dep to), so a bundle's
nightly version moves in lockstep with the lfx it was built against while
keeping the bundle's own base version (e.g. `0.1.0`) truthful.
"""
lfx_parsed = Version(lfx_version)
if lfx_parsed.dev is None:
msg = f"Expected a .devN nightly lfx version, got {lfx_version!r}"
raise ValueError(msg)
base = Version(bundle_base_version).base_version
return f"{base}.dev{lfx_parsed.dev}"
def rename_bundles_for_nightly(lfx_version: str) -> None:
"""Rename each `src/bundles/*` package to its `-nightly` distribution.
The stable bundles publish as `lfx-<name>` and pin `lfx>=X.Y`. During a
nightly build the workspace `lfx` becomes `lfx-nightly` (no stable `lfx`
matching the pin may exist on PyPI yet), so a `langflow-nightly` that still
depended on the stable `lfx-<name>` would drag in `lfx>=X.Y` and fail to
resolve. Give the bundles their own nightly track instead, mirroring how
lfx/base/main are renamed. For every bundle this:
* rewrites its `[project] name` `lfx-<name>` -> `lfx-<name>-nightly`
* rewrites its `[project] version` `0.1.0` -> `0.1.0.dev<N>`
* repoints the root `[tool.uv.sources]` workspace entry to the new name
* repoints the root `langflow` dependencies to `lfx-<name>-nightly==<dev>`,
including extras forms like `lfx-docling[local]>=...` in
`[project.optional-dependencies]` (the `[extra]` selector is preserved)
The bundle's own `lfx` dep is repinned separately by
`update_lfx_dep_in_bundles`. No-op when no bundles are present, and
idempotent for bundles already carrying a `-nightly` name.
"""
bundles_dir = BASE_DIR / "src" / "bundles"
if not bundles_dir.is_dir():
return
root_content = ROOT_PYPROJECT.read_text(encoding="utf-8")
for bundle_pyproject in sorted(bundles_dir.glob("*/pyproject.toml")):
data = tomllib.loads(bundle_pyproject.read_text(encoding="utf-8"))
old_name = data["project"]["name"]
if old_name.endswith("-nightly"):
continue
new_name = f"{old_name}-nightly"
new_version = _bundle_nightly_version(data["project"]["version"], lfx_version)
rel_path = str(bundle_pyproject.relative_to(BASE_DIR))
update_pyproject_name(rel_path, new_name)
update_pyproject_version(rel_path, new_version)
# Repoint the workspace source: `lfx-<name> = { workspace = true }`.
source_pattern = re.compile(rf"^{re.escape(old_name)}(\s*=\s*\{{ workspace = true \}})", re.MULTILINE)
if not source_pattern.search(root_content):
msg = f"Workspace source entry for {old_name} not found in {ROOT_PYPROJECT}"
raise ValueError(msg)
root_content = source_pattern.sub(rf"{new_name}\1", root_content)
# Repoint the root dependencies to the exact nightly pin. This covers
# both the bare main dep `"lfx-<name>>=0.1.0"` and any extras form in
# `[project.optional-dependencies]` such as `"lfx-docling[local]>=0.1.0"`.
# The optional `[extra]` is captured and re-emitted so the selector
# survives the rewrite (-> `"lfx-docling-nightly[local]==<dev>"`); a
# missed extras ref would stay `lfx-<name>` and leak to PyPI, where no
# `>=0.1.0` is published, breaking `uv lock`. The lookahead still
# requires a version operator right after the name+optional-extra so we
# match `lfx-arxiv>=...` / `lfx-docling[local]>=...` without also
# matching an already-renamed `lfx-arxiv-nightly`.
dep_pattern = re.compile(rf'"{re.escape(old_name)}(\[[^\]]+\])?(?=[<>=!~])[^"]*"')
if not dep_pattern.search(root_content):
msg = f"Root dependency on {old_name} not found in {ROOT_PYPROJECT}"
raise ValueError(msg)
root_content = dep_pattern.sub(rf'"{new_name}\g<1>=={new_version}"', root_content)
print(f"Renamed bundle {old_name} -> {new_name}=={new_version}")
ROOT_PYPROJECT.write_text(root_content, encoding="utf-8")
def update_lfx_for_nightly(lfx_tag: str, sdk_tag: str):
"""Update LFX package for nightly build.
"""Update the canonical ``lfx`` package for a nightly build.
Args:
lfx_tag: The nightly tag for LFX (e.g., "v0.1.0.dev0")
sdk_tag: The nightly tag for the SDK (e.g., "v0.1.0.dev0")
lfx_tag: The nightly tag for LFX (e.g., "v1.11.0.dev0").
sdk_tag: The nightly tag for the SDK (e.g., "v0.1.0.dev0").
"""
lfx_pyproject_path = "src/lfx/pyproject.toml"
# Update name to lfx-nightly
update_pyproject_name(lfx_pyproject_path, "lfx-nightly")
# Update version (strip 'v' prefix if present)
# Set the version (strip 'v' prefix if present); the package keeps its canonical `lfx` name.
version = lfx_tag.lstrip("v")
update_pyproject_version(lfx_pyproject_path, version)
# Update workspace dependency in root pyproject.toml
update_lfx_workspace_dep("pyproject.toml", "lfx-nightly")
# Re-pin lfx's SDK dependency to the exact canonical dev version.
sdk_version = sdk_tag.lstrip("v")
update_sdk_dependency_in_lfx(lfx_pyproject_path, sdk_version)
# Re-pin every bundle's lfx dep to the renamed workspace package so
# `uv lock` resolves cleanly. No-op when no bundles are present.
update_lfx_dep_in_bundles(version)
# Give each bundle its own `-nightly` distribution and repoint the root
# `langflow` deps + workspace sources at it, so `langflow-nightly` pulls
# `lfx-<name>-nightly` (which pins `lfx-nightly`) instead of the stable
# `lfx-<name>` (which pins an as-yet-unpublished stable `lfx`).
rename_bundles_for_nightly(version)
print(f"Updated LFX package to lfx-nightly version {version}")
print(f"Updated lfx to nightly version {version}")
def main():

View File

@ -4,8 +4,6 @@ import sys
from pathlib import Path
from update_lf_base_dependency import update_lfx_dep_in_base
from update_pyproject_name import update_pyproject_name
from update_pyproject_name import update_uv_dep as update_name_uv_dep
from update_pyproject_version import update_pyproject_version
from update_uv_dependency import update_uv_dep as update_version_uv_dep
@ -17,6 +15,10 @@ sys.path.append(str(current_dir))
def main():
"""Universal update script that handles both base and main updates in a single run.
The packages keep their CANONICAL names (``langflow``, ``langflow-base``) -- they are NOT
renamed to ``*-nightly``. This script only sets the nightly ``.devN`` versions and re-pins the
inter-package dependencies to exact canonical dev versions. See ``src/bundles/NIGHTLY.md``.
Usage:
update_pyproject_combined.py main <main_tag> <base_tag> <lfx_tag>
"""
@ -36,26 +38,22 @@ def main():
base_tag = sys.argv[3]
lfx_tag = sys.argv[4]
# Lockstep invariant: langflow-base-nightly's published version (set here from `base_tag`)
# and langflow-nightly's exact `==` pin on it (set below from the same `base_tag`) MUST come
# from the same value, so the latest langflow-nightly always pins a base version published in
# the same run. `pypi_nightly_tag.py` makes the main and base tags identical; keep both writes
# sourced from `base_tag` or the pin can reference a version that was never published.
# Lockstep invariant: langflow-base's published dev version (set here from `base_tag`) and
# langflow's exact `==` pin on it (set below from the same `base_tag`) MUST come from the same
# value, so the latest nightly `langflow` always pins a base version published in the same run.
# `pypi_nightly_tag.py` makes the main and base tags identical; keep both writes sourced from
# `base_tag` or the pin can reference a version that was never published.
# First handle base package updates
update_pyproject_name("src/backend/base/pyproject.toml", "langflow-base-nightly")
update_name_uv_dep("pyproject.toml", "langflow-base-nightly")
# First handle base package updates (canonical name kept).
update_pyproject_version("src/backend/base/pyproject.toml", base_tag)
# Update LFX dependency in langflow-base
# Update LFX dependency in langflow-base (exact canonical dev pin).
lfx_version = lfx_tag.lstrip("v")
update_lfx_dep_in_base("src/backend/base/pyproject.toml", lfx_version)
# Then handle main package updates
update_pyproject_name("pyproject.toml", "langflow-nightly")
update_name_uv_dep("pyproject.toml", "langflow-nightly")
# Then handle main package updates (canonical name kept).
update_pyproject_version("pyproject.toml", main_tag)
# Update dependency version (strip 'v' prefix if present)
# Update langflow-base dependency version (strip 'v' prefix if present).
base_version = base_tag.lstrip("v")
update_version_uv_dep(base_version)

View File

@ -1,10 +1,13 @@
"""Script to update SDK version for nightly builds."""
"""Update the canonical ``langflow-sdk`` version for nightly builds.
The SDK keeps its canonical ``langflow-sdk`` name -- it is NOT renamed to ``langflow-sdk-nightly`` --
and is published as a ``.devN`` pre-release. This script only sets the nightly version.
See ``src/bundles/NIGHTLY.md``.
"""
import sys
from pathlib import Path
from update_pyproject_name import update_pyproject_name
from update_pyproject_name import update_uv_dep as update_workspace_dep
from update_pyproject_version import update_pyproject_version
# Add the current directory to the path so we can import the other scripts
@ -13,17 +16,13 @@ sys.path.append(str(current_dir))
def update_sdk_for_nightly(sdk_tag: str):
"""Update SDK package for nightly build."""
"""Set the canonical ``langflow-sdk`` package version for a nightly build."""
sdk_pyproject_path = "src/sdk/pyproject.toml"
update_pyproject_name(sdk_pyproject_path, "langflow-sdk-nightly")
version = sdk_tag.lstrip("v")
update_pyproject_version(sdk_pyproject_path, version)
update_workspace_dep("pyproject.toml", "langflow-sdk-nightly")
print(f"Updated SDK package to langflow-sdk-nightly version {version}")
print(f"Updated langflow-sdk to nightly version {version}")
def main():

View File

@ -32,7 +32,8 @@ def update_uv_dep(base_version: str) -> None:
# Extract extras if present (e.g., "[complete]")
extras = match.group(3) if match.group(3) else ""
replacement = rf'\1"langflow-base-nightly{extras}=={base_version}"'
# Keep the canonical `langflow-base` name with an exact `==<dev>` pin.
replacement = rf'\1"langflow-base{extras}=={base_version}"'
# Replace the matched pattern with the new one
content = pattern.sub(replacement, content)

View File

@ -69,11 +69,13 @@ def _current_lfx_floor() -> str:
"""Return the ``lfx`` dependency floor for a freshly-ported bundle.
Reads the workspace lfx version from ``src/lfx/pyproject.toml`` and pins
``lfx>=X.Y.0,<(X+1).0.0`` -- floored at the current major.minor line,
capped below the next lfx major. Mirrors ``lfx_floor_spec`` in
``scripts/ci/sync_bundle_lfx_pin.py`` (each script is kept standalone, so
keep the two in step); that script re-syncs every existing bundle on
``make patch``.
``lfx>=X.Y.0.dev0,<(X+1).0.0`` -- floored at the current major.minor
line's first pre-release (the branch's own canonical ``X.Y.0.devN``
nightlies sort below a plain ``X.Y.0`` under PEP 440, so they must be
admitted by the floor), capped below the next lfx major. Mirrors
``lfx_floor_spec`` in ``scripts/ci/sync_bundle_lfx_pin.py`` (each script
is kept standalone, so keep the two in step); that script re-syncs every
existing bundle on ``make patch``.
"""
lfx_pyproject = (REPO_ROOT / "src" / "lfx" / "pyproject.toml").read_text(encoding="utf-8")
match = re.search(r'^version = "(\d+)\.(\d+)\.\d+', lfx_pyproject, re.MULTILINE)
@ -81,7 +83,7 @@ def _current_lfx_floor() -> str:
msg = "Could not read lfx version from src/lfx/pyproject.toml"
raise ValueError(msg)
major, minor = int(match.group(1)), int(match.group(2))
return f"lfx>={major}.{minor}.0,<{major + 1}.0.0"
return f"lfx>={major}.{minor}.0.dev0,<{major + 1}.0.0"
# ---------------------------------------------------------------------------

View File

@ -479,7 +479,14 @@ def get_lifespan(*, fix_migration=False, version=None):
await asyncio.sleep(refresh_interval_seconds)
models_dev_refresh_task = asyncio.create_task(refresh_models_dev_periodically())
# LANGFLOW_MODELS_DEV_REFRESH=false disables the live models.dev
# fetch. Tests set this: the startup fetch otherwise fires from a
# background task during whatever test is running, hitting the
# network and tripping event-loop-block detectors (pyleak).
if os.getenv("LANGFLOW_MODELS_DEV_REFRESH", "true").lower() not in ("false", "0", "no"):
models_dev_refresh_task = asyncio.create_task(refresh_models_dev_periodically())
else:
await logger.adebug("models.dev refresh disabled via LANGFLOW_MODELS_DEV_REFRESH")
# v1 and project MCP server context managers
from langflow.api.v1.mcp import start_streamable_http_manager

View File

@ -38,10 +38,17 @@ def _get_version_info():
except (ImportError, metadata.PackageNotFoundError):
pass
else:
# The canonical `langflow` / `langflow-base` distribution matches first, and the
# nightly now publishes under that canonical name as a `.devN` pre-release. Derive the
# "Nightly" label from the `.dev` version marker (not the package name) so the startup
# banner and telemetry `package` field still identify nightlies correctly.
display = display_name
if "dev" in prerelease_version and "Nightly" not in display:
display = f"{display_name} Nightly"
return {
"version": prerelease_version,
"main_version": version,
"package": display_name,
"package": display,
}
if __version__ is None:

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,21 @@ def disable_rate_limiting():
os.environ.pop("LANGFLOW_RATE_LIMIT_ENABLED", None)
@pytest.fixture(scope="session", autouse=True)
def disable_models_dev_refresh():
"""Keep the models.dev background refresh out of tests.
Every app boot otherwise launches a lifespan task that fetches
https://models.dev/api.json mid-test, which both hits the network and
trips event-loop-block detectors (pyleak) in whatever test happens to be
running when the request lands. The bundled static model lists are used
instead, which is also deterministic.
"""
os.environ["LANGFLOW_MODELS_DEV_REFRESH"] = "false"
yield
os.environ.pop("LANGFLOW_MODELS_DEV_REFRESH", None)
# TODO: Revert this to True once bb.functions[func].can_block_in("http/client.py", "_safe_read") is fixed
@pytest.fixture(autouse=False)
def blockbuster(request):

View File

@ -36,15 +36,18 @@ mod = _load_module()
class TestLfxFloorSpec:
# The .dev0 floor is load-bearing: nightlies are canonical X.Y.0.devN
# pre-releases, which PEP 440 sorts BELOW X.Y.0, so a plain >=X.Y.0
# floor makes the branch's own nightly lfx unresolvable.
@pytest.mark.parametrize(
("version", "expected"),
[
("1.10.0", "lfx>=1.10.0,<2.0.0"),
("1.10.3", "lfx>=1.10.0,<2.0.0"), # patch within a minor -> same floor
("1.11.0", "lfx>=1.11.0,<2.0.0"),
("2.0.0", "lfx>=2.0.0,<3.0.0"),
("v1.10.0", "lfx>=1.10.0,<2.0.0"), # leading v tolerated
("10.4.2", "lfx>=10.4.0,<11.0.0"), # multi-digit major
("1.10.0", "lfx>=1.10.0.dev0,<2.0.0"),
("1.10.3", "lfx>=1.10.0.dev0,<2.0.0"), # patch within a minor -> same floor
("1.11.0", "lfx>=1.11.0.dev0,<2.0.0"),
("2.0.0", "lfx>=2.0.0.dev0,<3.0.0"),
("v1.10.0", "lfx>=1.10.0.dev0,<2.0.0"), # leading v tolerated
("10.4.2", "lfx>=10.4.0.dev0,<11.0.0"), # multi-digit major
],
)
def test_floor(self, version, expected):
@ -62,7 +65,7 @@ class TestLfxFloorSpec:
class TestRewriteLfxDep:
FLOOR = "lfx>=1.10.0,<2.0.0"
FLOOR = "lfx>=1.10.0.dev0,<2.0.0"
def test_rewrites_bare_floor(self):
assert mod.rewrite_lfx_dep(' "lfx>=0.5.0",', self.FLOOR) == f' "{self.FLOOR}",'
@ -121,11 +124,11 @@ class TestSyncBundles:
def test_sync_updates_and_reports(self, tmp_path):
bundles = tmp_path / "bundles"
self._make_bundle(bundles, "arxiv", "lfx>=0.5.0")
self._make_bundle(bundles, "ibm", "lfx>=1.10.0,<2.0.0") # already correct
self._make_bundle(bundles, "ibm", "lfx>=1.10.0.dev0,<2.0.0") # already correct
results = dict(mod.sync_bundles("1.10.0", bundles))
assert results == {"arxiv": True, "ibm": False} # arxiv changed, ibm no-op
assert '"lfx>=1.10.0,<2.0.0"' in (bundles / "arxiv" / "pyproject.toml").read_text()
assert '"lfx>=1.10.0.dev0,<2.0.0"' in (bundles / "arxiv" / "pyproject.toml").read_text()
def test_sync_idempotent(self, tmp_path):
bundles = tmp_path / "bundles"

View File

@ -225,7 +225,13 @@ def test_multi_process_visibility(tmp_path):
args=(str(shared_dir), "flow-mp", "component_added", "from-other-process"),
)
proc.start()
proc.join(timeout=10)
# Generous bound: a spawned child cold-imports the full langflow package
# (plus coverage's multiprocessing hooks in CI), which can take >10s on a
# loaded CI runner sharing 4 vCPUs with another xdist worker.
proc.join(timeout=60)
if proc.is_alive():
proc.kill()
proc.join(timeout=5)
assert proc.exitcode == 0, "Child process failed to append event"
reader = FlowEventsService(cache_dir=shared_dir)

View File

@ -104,7 +104,8 @@ class TestGetVersionInfo:
assert result["version"] == "1.0.0.dev123"
assert result["main_version"] == "1.0.0"
assert result["package"] == "Langflow Base"
# A `.dev` version is labeled a nightly regardless of which package name resolved it.
assert result["package"] == "Langflow Base Nightly"
@patch("langflow.utils.version.metadata")
def test_get_version_info_nightly_package(self, mock_metadata):
@ -150,6 +151,30 @@ class TestGetVersionInfo:
assert result["main_version"] == "1.0.0"
assert result["package"] == "Langflow Base Nightly"
@patch("langflow.utils.version.metadata")
def test_get_version_info_canonical_dev_is_nightly(self, mock_metadata):
"""A canonical ``langflow`` install at a ``.dev`` version is labeled a nightly.
The nightly now publishes as ``langflow==X.Y.Z.devN`` (the canonical name matches first),
so the "Nightly" label must come from the ``.dev`` marker, not the package name.
"""
from importlib import metadata as real_metadata
mock_metadata.PackageNotFoundError = real_metadata.PackageNotFoundError
def mock_version(pkg_name):
if pkg_name == "langflow":
return "1.11.0.dev5"
raise mock_metadata.PackageNotFoundError
mock_metadata.version.side_effect = mock_version
result = _get_version_info()
assert result["version"] == "1.11.0.dev5"
assert result["main_version"] == "1.11.0"
assert result["package"] == "Langflow Nightly"
@patch("langflow.utils.version.metadata")
def test_get_version_info_no_package_found(self, mock_metadata):
"""Test getting version info when no package is found."""

135
src/bundles/NIGHTLY.md Normal file
View File

@ -0,0 +1,135 @@
# Bundles & the nightly build
> **Status:** Approach A (canonical pre-releases) is **implemented in this PR**. The guard against
> premature activation is the [activation gate](#activation-gate-must-read) below — **not** merge or
> draft state: this must **NOT** be merged/activated until stable `lfx 1.10.0` is published **and**
> the nightly's base version has moved to the next minor. Sibling docs: [PORTING.md](./PORTING.md).
## Goal
Have **both** the normal release and the nightly depend on the regular published `lfx-*` bundle
packages, and **stop producing nightly bundle packages** (`lfx-arxiv-nightly`, …). The nightly
bundle track is pure overhead — bundle code is low-churn and does not need a per-night rebuild.
## The bundles
Four bundles are extracted as standalone PyPI packages (the only dirs here with a `pyproject.toml`):
| Package | Dir | Version | PyPI |
| ---------------- | ------------------------- | ------- | ------- |
| `lfx-arxiv` | `src/bundles/arxiv` | `0.1.0` | ✅ live |
| `lfx-docling` | `src/bundles/docling` | `0.1.0` | ✅ live |
| `lfx-duckduckgo` | `src/bundles/duckduckgo` | `0.1.0` | ✅ live |
| `lfx-ibm` | `src/bundles/ibm` | `0.1.0` | ✅ live |
Each pins `lfx>=1.10.0,<2.0.0` and is wired into the root `pyproject.toml` as a direct dependency
(`lfx-*>=0.1.0`), a `[tool.uv.sources]` workspace entry, and a `[tool.uv.workspace]` member. **The
bundles are unchanged by this PR.**
## The normal release already meets the goal
Stable `langflow 1.10.0``lfx-*>=0.1.0``lfx>=1.10.0,<2.0.0` → stable `lfx`. One consistent,
canonical `lfx`. No change needed there.
## Background: why the nightly renamed the bundles (removed by this PR)
The old nightly published the core as a **separate distribution**, `lfx-nightly` — but the rename
only rewrote `[project].name`; the wheel still shipped the **same `lfx/` import package**. So a
`langflow-nightly` depending on a *stable* bundle would drag in **stable `lfx` alongside
`lfx-nightly`** — two distributions both owning `site-packages/lfx/` → an install-time collision.
To avoid that, the nightly gave the bundles their own `-nightly` track (`update_lfx_dep_in_bundles`
+ `rename_bundles_for_nightly` in `update_lfx_version.py`). Approach A removes the dual-distribution
design entirely, so that bundle renaming is no longer needed.
## What this PR changes (Approach A — canonical pre-releases)
Publish the nightly under the **canonical** package names as `.devN` pre-releases (`lfx==X.Y.Z.devN`,
`langflow==…`, `langflow-base==…`, `langflow-sdk==…`) instead of separate `*-nightly` distributions.
A single canonical `lfx` then exists, so the stable bundles resolve cleanly — **no bundle changes**.
- **Stop renaming the core.** `update_lfx_version.py`, `update_pyproject_combined.py`,
`update_sdk_version.py` no longer rename to `*-nightly`; they only set the `.devN` version and
re-pin inter-package deps to **exact canonical dev versions** (`langflow-base[complete]==<dev>`,
`lfx==<dev>`, `langflow-sdk==<dev>` — via `update_uv_dependency.py` / `update_lf_base_dependency.py`).
The exact dev pins also enable pre-release resolution down the tree, so the bundles' `lfx>=…`
range latches onto the dev `lfx`.
- **Drop the bundle nightly track.** `update_lfx_dep_in_bundles()` and `rename_bundles_for_nightly()`
are deleted; bundles keep their stable names + `lfx>=1.10.0,<2.0.0` pins.
- **Version math.** `pypi_nightly_tag.py` / `lfx_nightly_tag.py` / `sdk_nightly_tag.py` count `.devN`
against the **canonical** PyPI histories (`langflow` / `langflow-base` / `lfx` / `langflow-sdk`),
not the `*-nightly` projects. The base version is still read from the pyproject of the latest
`release-*` branch the nightly builds from (see `nightly_build.yml`'s `resolve-release-branch`),
so it tracks the release cadence automatically.
- **Publish workflow.** `release_nightly.yml` publishes canonical pre-releases; the bundle build
step, the `dist-nightly-bundles` artifact, the `publish-nightly-bundles` job, and its gate in
`publish-nightly-main` are removed. Verify steps now expect canonical names; the main wheel glob
is `dist/langflow-*.whl`.
## Activation gate (MUST READ)
Do **not** merge/activate until **both**:
1. **Stable `lfx 1.10.0` is published to PyPI** (the bundles' `lfx>=1.10.0` floor must be satisfiable
by a real release), **and**
2. **The nightly's base version is the next minor** (i.e. the latest `release-*` branch is
`release-1.11.0`, so nightlies are `1.11.0.devN`).
Why (2) is not optional: a bundle pins `lfx>=1.10.0,<2.0.0`, and PEP 440 sorts `1.10.0.devN`
**below** `1.10.0`. So a `1.10.0.devN` nightly is **not** `>= 1.10.0``langflow-base`'s exact
`lfx==1.10.0.devN` pin would directly conflict with the bundle's `lfx>=1.10.0` floor and resolution
**fails**. Only a next-minor dev (`1.11.0.devN`, which *is* `>= 1.10.0`) resolves. The nightly runs
daily from **main's** workflow definition, so merging this before the gate would break the live
nightly on the next run.
> **Post-activation fix (2026-06):** condition (2) as stated was still fragile — the `release-1.11.0`
> fork's `make patch v=1.11.0` re-synced every bundle floor to `lfx>=1.11.0`
> (`scripts/ci/sync_bundle_lfx_pin.py`), which sorts **above** that same branch's `1.11.0.devN`
> nightlies and reintroduced exactly this conflict on the very first `1.11.0.dev0` nightly (the
> workspace-built bundle's metadata shadows the satisfiable PyPI `0.1.1` in the
> `uv pip install dist/*.whl` test step). The synced floor format is now
> `lfx>=X.Y.0.dev0,<(X+1).0.0` — `X.Y.0.dev0` is the lowest version PEP 440 admits in the minor
> line, so every `devN` / `rcN` / final satisfies it while older lines stay excluded, and the gate
> can no longer regress on future minor forks.
## A1 vs A2 + remaining follow-ups (decide before activating)
This PR implements the **A1** publish behavior: the separate `langflow-nightly` / `langflow-base-nightly`
/ `lfx-nightly` distributions go away; the nightly is installed via `pip install --pre langflow`.
Addressed here (consumers of the dropped `*-nightly` PyPI names, so the cutover stays self-consistent):
- **Runtime nightly detection.** `_get_version_info` (`src/backend/base/langflow/utils/version.py`,
`test_version.py`) now derives the "Nightly" label from the `.dev` version marker — the canonical
`langflow` distribution matches first, so the package *name* alone no longer identifies a nightly.
Keeps the startup banner and telemetry `package` field correct.
- **CI nightly consumers.** `ci.yml`'s `check-nightly-status` inspects the latest `.devN` release of
the canonical `langflow` project (not `langflow-nightly`); `db-migration-validation.yml` installs
the nightly as `langflow[postgresql]==<dev>` instead of `langflow-nightly[...]`.
- **LFX install doc.** `src/lfx/README.md` nightly install is now `uv pip install --pre lfx`.
Still open (deferred by design — decisions, not blockers):
- **Docker nightly image.** `langflowai/langflow-nightly` (Docker Hub) is independent of the PyPI
name and works as-is; decide whether to keep or rename.
- **Website install docs.** Any `pip install langflow-nightly` instructions on the docs site (not in
this repo) should become `pip install --pre langflow`.
- **A2 alternative (preserve the install name).** Instead of dropping `langflow-nightly`, keep it as
a thin meta-package pinning `langflow==X.Y.Z.devN`. The scripts already produce exact dev pins, so
A2 only adds a meta-package publish step and leaves the `pip install langflow-nightly` UX intact.
## Approach B (alternative, not taken)
Move each bundle's `lfx` dependency into an extra (e.g. `lfx-<name>[standalone]`) so the same wheel
is safe inside a `lfx-nightly` env; keep the `-nightly` core and the `pip install langflow-nightly`
UX. Downside: standalone `pip install lfx-arxiv` no longer auto-installs `lfx`, and bundles must
re-publish as `0.1.1`.
## Verification (run against this branch)
- `scripts/ci/test_pypi_nightly_tag.py` passes (15/15) with canonical URLs.
- Tag scripts run live against canonical PyPI and emit `vX.Y.Z.dev0` (no `*-nightly` counted).
- Dry-run `update_sdk_version.py` / `update_lfx_version.py` / `update_pyproject_combined.py` with
`v1.11.0.dev0` tags → all packages keep canonical names; versions set; pins become
`langflow-base[complete]==1.11.0.dev0`, `lfx==1.11.0.dev0`, `langflow-sdk==…`; **no** `src/bundles/*`
file is touched.
- Both workflows are valid YAML; no `publish-nightly-bundles` job remains.

View File

@ -15,7 +15,7 @@ keywords = ["langflow", "lfx", "extension", "bundle", "arxiv", "search"]
# but we list it here as a direct runtime dep so the bundle keeps building
# even if lfx drops it later.
dependencies = [
"lfx>=1.10.0,<2.0.0",
"lfx>=1.10.0.dev0,<2.0.0",
"defusedxml>=0.7.1,<1.0.0",
]

View File

@ -14,7 +14,7 @@ keywords = ["langflow", "lfx", "extension", "bundle", "docling", "documents"]
# base dependency because the bundle exchanges DoclingDocument objects even when
# conversion happens remotely.
dependencies = [
"lfx>=1.10.0,<2.0.0",
"lfx>=1.10.0.dev0,<2.0.0",
"httpx>=0.28.1,<1.0.0",
"docling-core>=2.36.1,<3.0.0",
]

View File

@ -18,7 +18,7 @@ keywords = ["langflow", "lfx", "extension", "bundle", "duckduckgo", "search"]
# "lfx": {"compat": [...]} contract. langchain-community uses the same
# range the lfx tree does to keep co-installation lockstep.
dependencies = [
"lfx>=1.10.0,<2.0.0",
"lfx>=1.10.0.dev0,<2.0.0",
"langchain-community>=0.4.1,<1.0.0",
"ddgs>=9.0.0",
]

View File

@ -26,7 +26,7 @@ keywords = ["langflow", "lfx", "extension", "bundle", "ibm", "db2", "watsonx", "
# 3.10-compatible build elsewhere. Kept in lockstep with langflow-base.
# * ``langchain-community`` provides the helpers DB2VS leans on.
dependencies = [
"lfx>=1.10.0,<2.0.0",
"lfx>=1.10.0.dev0,<2.0.0",
"langchain-community>=0.4.1,<1.0.0",
"ibm-db>=3.2.9,<4.0.0; sys_platform != 'linux' or platform_machine != 'aarch64'",
"ibm-watsonx-ai>=1.3.1,<2.0.0",

View File

@ -84,10 +84,10 @@ LFX can be installed in multiple ways. If you have installed Langflow OSS versio
uv pip install lfx
```
To install the latest nightly version of LFX:
To install the latest nightly (pre-release) version of LFX:
```bash
uv pip install lfx-nightly
uv pip install --pre lfx
```
To run `lfx` commands, continue to [lfx serve](#serve-the-simple-agent-starter-flow-with-lfx-serve) or [lfx run](#run-the-simple-agent-flow-with-lfx-run).

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
"""Logical groupings of Langflow settings.
Each module defines a ``BaseModel`` mixin that owns a cohesive subset of fields
plus their intra-group validators. They are composed into the final
``Settings`` class in :mod:`lfx.services.settings.base`.
Mixins inherit from ``BaseModel`` (not ``BaseSettings``) and are not intended
to be instantiated directly.
"""
from lfx.services.settings.groups.cache import CacheSettings
from lfx.services.settings.groups.components import ComponentsSettings
from lfx.services.settings.groups.database import DatabaseSettings
from lfx.services.settings.groups.mcp import McpSettings
from lfx.services.settings.groups.observability import ObservabilitySettings
from lfx.services.settings.groups.paths import PathSettings
from lfx.services.settings.groups.runtime import RuntimeSettings
from lfx.services.settings.groups.security import SecuritySettings
from lfx.services.settings.groups.server import ServerSettings
from lfx.services.settings.groups.storage import StorageSettings
from lfx.services.settings.groups.telemetry import TelemetrySettings
from lfx.services.settings.groups.ui import UiSettings
from lfx.services.settings.groups.variables import VariablesSettings
__all__ = [
"CacheSettings",
"ComponentsSettings",
"DatabaseSettings",
"McpSettings",
"ObservabilitySettings",
"PathSettings",
"RuntimeSettings",
"SecuritySettings",
"ServerSettings",
"StorageSettings",
"TelemetrySettings",
"UiSettings",
"VariablesSettings",
]

View File

@ -0,0 +1,43 @@
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, field_validator
class CacheSettings(BaseModel):
"""In-memory and Redis cache settings."""
cache_type: Literal["async", "redis", "memory"] = "async"
"""The cache backend: 'async' (default in-memory), 'memory' (sync in-memory), or 'redis'."""
cache_expire: int = 3600
"""The cache expire in seconds."""
cache_dir: str | None = None
"""Directory used by FlowEventsService for cross-worker event storage. Defaults to a temp dir if not set."""
langchain_cache: str = "InMemoryCache"
# Redis
redis_host: str = "localhost"
redis_port: int = 6379
redis_db: int = 0
redis_url: str | None = None
redis_cache_expire: int = 3600
@field_validator("cache_dir", mode="before")
@classmethod
def validate_cache_dir(cls, value):
"""Validate and normalize cache_dir path.
If not set, returns None and the factory will fall back to config_dir.
If set, resolves to an absolute path and creates the directory if needed.
"""
if not value:
return None
if isinstance(value, str):
value = Path(value)
# Resolve to absolute path to handle relative paths correctly
value = value.resolve()
if not value.exists():
value.mkdir(parents=True, exist_ok=True)
return str(value)

View File

@ -0,0 +1,154 @@
import os
from pathlib import Path
from pydantic import BaseModel, field_validator, model_validator
from lfx.constants import BASE_COMPONENTS_PATH
from lfx.log.logger import logger
class ComponentsSettings(BaseModel):
"""Component discovery, indexing, and startup-load behavior."""
components_path: list[str] = []
"""List of paths to custom components.
Security: This setting defines an allow-list of custom components
permitted to execute, even when LANGFLOW_ALLOW_CUSTOM_COMPONENTS is False.
"""
components_index_path: str | None = None
"""Path or URL to a prebuilt component index JSON file.
If None, uses the built-in index at lfx/_assets/component_index.json.
Set to a file path (e.g., '/path/to/index.json') or URL (e.g., 'https://example.com/index.json')
to use a custom index.
"""
load_flows_path: str | None = None
load_flows_overwrite_on_name_match: bool = False
"""When a flow loaded from ``load_flows_path`` shares a name with an existing DB row but has
a different id, overwrite the existing row's content from the file.
Default ``False`` preserves user edits made in the UI on restart: name-matched rows are
skipped with a warning instead of being silently overwritten when file UUIDs regenerate.
(Pre-1.10.0 this case raised ``IntegrityError`` and crashed startup; the loader now boots
successfully either way.) Set ``True`` to opt into "prepackaged flows are the source of
truth on restart" semantics, typically for CI/CD pipelines.
"""
bundle_urls: list[str] = []
lazy_load_components: bool = False
"""If set to True, Langflow will only partially load components at startup and fully load them on demand.
This significantly reduces startup time but may cause a slight delay when a component is first used."""
# Starter Projects
create_starter_projects: bool = True
"""If set to True, Langflow will create starter projects. If False, skips all starter project setup.
Note that this doesn't check if the starter projects are already loaded in the db;
this is intended to be used to skip all startup project logic."""
update_starter_projects: bool = True
"""If set to True, Langflow will update starter projects."""
# Extension reload (Mode A only)
enable_extension_reload: bool = False
"""If True, registers ``POST /api/v1/extensions/{id}/bundles/{name}/reload``
so authenticated users can hot-swap a Bundle's components in-process.
This is a Mode A (local-dev / pip-installed) facility only. In Mode B/C
(Docker image with baked-in bundles) Bundle changes require an image
rebuild and the in-process reload route would mask the real deploy
pipeline. Defaults to ``False`` so self-hosted / production deployments
do not expose runtime imports through an HTTP endpoint without an
explicit opt-in. Set ``LANGFLOW_ENABLE_EXTENSION_RELOAD=true`` in your
local dev environment to turn it on."""
@field_validator("components_path", mode="before")
@classmethod
def set_components_path(cls, value):
"""Processes and updates the components path list, incorporating environment variable overrides.
If the `LANGFLOW_COMPONENTS_PATH` environment variable is set and points to an existing path, it is
appended to the provided list if not already present. If the input list is empty or missing, it is
set to an empty list.
"""
env_value = os.getenv("LANGFLOW_COMPONENTS_PATH")
if env_value:
logger.debug("Adding LANGFLOW_COMPONENTS_PATH to components_path")
# Split on os.pathsep so multi-entry env vars
# ("/path/A:/path/B" on POSIX, "C:\\a;D:\\b" on Windows) are
# parsed as multiple components paths instead of one literal
# non-existent path. Empty segments (e.g. trailing pathsep) are
# ignored.
for raw_entry in env_value.split(os.pathsep):
entry = raw_entry.strip()
if not entry:
continue
if not Path(entry).exists():
# Surface at warning so a typo in LANGFLOW_COMPONENTS_PATH
# is visible in default log levels rather than silently
# producing zero components and zero diagnostics. The
# extension loader emits a typed ``inline-path-missing``
# warning at the same layer for events-pipeline consumers.
logger.warning(f"Skipping non-existent components path: {entry}")
continue
if entry not in value:
value.append(entry)
logger.debug(f"Appending {entry} to components_path")
if not value:
value = [BASE_COMPONENTS_PATH]
elif isinstance(value, Path):
value = [str(value)]
elif isinstance(value, list):
value = [str(p) if isinstance(p, Path) else p for p in value]
return value
@model_validator(mode="after")
def _enforce_components_paths_override(self):
"""Strip env-var-provided component paths when their bypass is disabled.
When ``allow_custom_components`` is False the server only trusts components
matching built-in templates. By default ``LANGFLOW_COMPONENTS_PATH`` and
``LANGFLOW_COMPONENTS_INDEX_PATH`` still contribute to that trust set (an
admin-curated allow-list). Setting ``allow_components_paths_override=False``
disables that bypass: here we remove the env-contributed entries so nothing
downstream loads or trusts them.
"""
if self.allow_custom_components or self.allow_components_paths_override:
return self
env_components_path = os.getenv("LANGFLOW_COMPONENTS_PATH")
if env_components_path:
# The env var may be a comma-separated list; CustomSource splits it
# before the field validator runs, so self.components_path contains
# individual entries rather than the raw comma-joined string.
# In-place removal avoids re-triggering ``set_components_path``, which
# would re-read LANGFLOW_COMPONENTS_PATH and append the paths again.
env_paths = [p.strip() for p in env_components_path.split(",") if p.strip()]
stripped_any = False
for env_path in env_paths:
while env_path in self.components_path:
self.components_path.remove(env_path)
stripped_any = True
if stripped_any:
logger.warning(
"Ignoring LANGFLOW_COMPONENTS_PATH=%s: "
"LANGFLOW_ALLOW_CUSTOM_COMPONENTS=False and "
"LANGFLOW_ALLOW_COMPONENTS_PATHS_OVERRIDE=False.",
env_components_path,
)
# Only strip the index path when it came from the env var, mirroring the
# components_path handling above. A value set via config/YAML is not part of
# the env-var bypass this flag governs, so leave it untouched.
env_components_index_path = os.getenv("LANGFLOW_COMPONENTS_INDEX_PATH")
if env_components_index_path and self.components_index_path == env_components_index_path:
logger.warning(
"Ignoring LANGFLOW_COMPONENTS_INDEX_PATH=%s: "
"LANGFLOW_ALLOW_CUSTOM_COMPONENTS=False and "
"LANGFLOW_ALLOW_COMPONENTS_PATHS_OVERRIDE=False.",
self.components_index_path,
)
self.components_index_path = None
return self

View File

@ -0,0 +1,157 @@
import os
from pathlib import Path
from shutil import copy2
from pydantic import BaseModel, field_validator
from lfx.log.logger import logger
from lfx.utils.util_strings import is_valid_database_url, sanitize_database_url
class DatabaseSettings(BaseModel):
"""Database connection, pooling, and migration settings.
Note: ``database_url`` is validated at the :class:`Settings` level because
it reads ``config_dir`` from :class:`PathSettings`.
"""
save_db_in_config_dir: bool = False
"""Define if langflow database should be saved in LANGFLOW_CONFIG_DIR or in the langflow directory
(i.e. in the package directory)."""
database_url: str | None = None
"""Database URL for Langflow. If not provided, Langflow will use a SQLite database.
The driver shall be an async one like `sqlite+aiosqlite` (`sqlite` and `postgresql`
will be automatically converted to the async drivers `sqlite+aiosqlite` and
`postgresql+psycopg` respectively)."""
database_connection_retry: bool = False
"""If True, Langflow will retry to connect to the database if it fails."""
pool_size: int = 20
"""The number of connections to keep open in the connection pool.
For high load scenarios, this should be increased based on expected concurrent users."""
max_overflow: int = 30
"""The number of connections to allow that can be opened beyond the pool size.
Should be 2x the pool_size for optimal performance under load."""
db_connect_timeout: int = 30
"""The number of seconds to wait before giving up on a lock to released or establishing a connection to the
database."""
migration_lock_namespace: str | None = None
"""Optional namespace identifier for PostgreSQL advisory lock during migrations.
If not provided, a hash of the database URL will be used. Useful when multiple Langflow
instances share the same database and need coordinated migration locking."""
sqlite_pragmas: dict | None = {"synchronous": "NORMAL", "journal_mode": "WAL", "busy_timeout": 30000}
"""SQLite pragmas to use when connecting to the database."""
db_driver_connection_settings: dict | None = None
"""Database driver connection settings."""
db_connection_settings: dict | None = {
"pool_size": 20,
"max_overflow": 30,
"pool_timeout": 30,
"pool_pre_ping": True,
"pool_recycle": 1800,
"echo": False,
}
"""Database connection settings optimized for high load scenarios.
Note: These settings are most effective with PostgreSQL. For SQLite:
- Reduce pool_size and max_overflow if experiencing lock contention
- SQLite has limited concurrent write capability even with WAL mode
- Best for read-heavy or moderate write workloads
Settings:
- pool_size: Number of connections to maintain (increase for higher concurrency)
- max_overflow: Additional connections allowed beyond pool_size
- pool_timeout: Seconds to wait for an available connection
- pool_pre_ping: Validates connections before use to prevent stale connections
- pool_recycle: Seconds before connections are recycled (prevents timeouts)
- 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."""
@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("database_url", mode="before")
@classmethod
def set_database_url(cls, value, info):
if value and not is_valid_database_url(value):
sanitized = sanitize_database_url(value)
msg = f"Invalid database_url provided: '{sanitized}'"
raise ValueError(msg)
if langflow_database_url := os.getenv("LANGFLOW_DATABASE_URL"):
value = langflow_database_url
logger.debug("Using LANGFLOW_DATABASE_URL env variable")
else:
if not info.data.get("config_dir"):
msg = "config_dir not set, please set it or provide a database_url"
raise ValueError(msg)
from lfx.utils.version import get_version_info
from lfx.utils.version import is_pre_release as langflow_is_pre_release
version = get_version_info()["version"]
is_pre_release = langflow_is_pre_release(version)
if info.data["save_db_in_config_dir"]:
database_dir = info.data["config_dir"]
else:
try:
import langflow
database_dir = Path(langflow.__file__).parent.resolve()
except ImportError:
database_dir = Path(__file__).parent.parent.parent.parent.resolve()
pre_db_file_name = "langflow-pre.db"
db_file_name = "langflow.db"
new_pre_path = f"{database_dir}/{pre_db_file_name}"
new_path = f"{database_dir}/{db_file_name}"
final_path = None
if is_pre_release:
if Path(new_pre_path).exists():
final_path = new_pre_path
elif Path(new_path).exists() and info.data["save_db_in_config_dir"]:
logger.debug("Copying existing database to new location")
copy2(new_path, new_pre_path)
logger.debug(f"Copied existing database to {new_pre_path}")
elif Path(f"./{db_file_name}").exists() and info.data["save_db_in_config_dir"]:
logger.debug("Copying existing database to new location")
copy2(f"./{db_file_name}", new_pre_path)
logger.debug(f"Copied existing database to {new_pre_path}")
else:
logger.debug(f"Creating new database at {new_pre_path}")
final_path = new_pre_path
elif Path(new_path).exists():
final_path = new_path
elif Path(f"./{db_file_name}").exists():
try:
logger.debug("Copying existing database to new location")
copy2(f"./{db_file_name}", new_path)
logger.debug(f"Copied existing database to {new_path}")
except OSError:
logger.exception("Failed to copy database, using default path")
new_path = f"./{db_file_name}"
else:
final_path = new_path
if final_path is None:
final_path = new_pre_path if is_pre_release else new_path
value = f"sqlite:///{final_path}"
return value

View File

@ -0,0 +1,94 @@
import re
from pydantic import BaseModel, field_validator
from lfx.log.logger import logger
class McpSettings(BaseModel):
"""MCP server, session manager, and composer settings."""
mcp_base_url: str = ""
"""External base URL used to build MCP server URLs in the UI configuration JSON
(e.g. 'https://langflow.example.com'). When empty, the frontend falls back to
the browser's window.location.origin."""
mcp_server_timeout: int = 20
"""The number of seconds to wait before giving up on establishing a connection to the MCP server."""
mcp_tool_execution_timeout: float = 180.0
"""Maximum seconds to wait for MCP tool execution before timing out.
Default is 180 seconds (3 minutes) to support long-running operations.
Supports decimal values for sub-second timeouts (e.g., 0.5 for 500ms).
Individual components can override this with their own timeout setting.
Must be a positive number greater than 0."""
@field_validator("mcp_tool_execution_timeout")
@classmethod
def validate_mcp_tool_execution_timeout(cls, v: float) -> float:
"""Validate that mcp_tool_execution_timeout is positive."""
if v <= 0:
msg = "mcp_tool_execution_timeout must be greater than 0"
raise ValueError(msg)
return v
# ---------------------------------------------------------------------
# MCP Session-manager tuning
# ---------------------------------------------------------------------
mcp_max_sessions_per_server: int = 10
"""Maximum number of MCP sessions to keep per unique server (command/url).
Mirrors the default constant MAX_SESSIONS_PER_SERVER in util.py. Adjust to
control resource usage or concurrency per server."""
mcp_session_idle_timeout: int = 400 # seconds (~6.7 minutes)
"""How long (in seconds) an MCP session can stay idle before the background
cleanup task disposes of it."""
mcp_session_cleanup_interval: int = 120 # seconds
"""Frequency (in seconds) at which the background cleanup task wakes up to
reap idle sessions."""
# MCP Server
mcp_server_enabled: bool = True
"""If set to False, Langflow will not enable the MCP server."""
mcp_server_enable_progress_notifications: bool = False
"""If set to False, Langflow will not send progress notifications in the MCP server."""
# Add projects to MCP servers automatically on creation
add_projects_to_mcp_servers: bool = True
"""If set to True, newly created projects will be added to the user's MCP servers config automatically."""
# MCP Composer
mcp_composer_enabled: bool = True
"""If set to False, Langflow will not start the MCP Composer service."""
mcp_composer_version: str = "==0.1.0.8.10"
"""Version constraint for mcp-composer when using uvx. Uses PEP 440 syntax."""
# MCP Server management
mcp_servers_locked: bool = False
"""If set to True, users cannot add or modify MCP servers via the UI/API.
This control is independent from ``embedded_mode`` and must be enabled
explicitly when you want to lock MCP server management.
"""
@field_validator("mcp_composer_version", mode="before")
@classmethod
def validate_mcp_composer_version(cls, value):
"""Ensure the version string has a version specifier prefix.
If a bare version like '0.1.0.7' is provided, prepend '~=' to allow patch updates.
Supports PEP 440 specifiers: ==, !=, <=, >=, <, >, ~=, ===
"""
if not value:
return "==0.1.0.8.10" # Default
specifiers = ["===", "==", "!=", "<=", ">=", "~=", "<", ">"]
if any(value.startswith(spec) for spec in specifiers):
return value
if re.match(r"^\d+(\.\d+)*", value):
logger.debug(f"Adding ~= prefix to bare version '{value}' -> '~={value}'")
return f"~={value}"
return value

View File

@ -0,0 +1,23 @@
from pydantic import BaseModel
class ObservabilitySettings(BaseModel):
"""Metrics exposure and historical record retention."""
prometheus_enabled: bool = False
"""If set to True, Langflow will expose Prometheus metrics."""
prometheus_port: int = 9090
"""The port on which Langflow will expose Prometheus metrics. 9090 is the default port."""
max_transactions_to_keep: int = 3000
"""The maximum number of transactions to keep in the database."""
max_vertex_builds_to_keep: int = 3000
"""The maximum number of vertex builds to keep in the database."""
max_vertex_builds_per_vertex: int = 50
"""The maximum number of builds to keep per vertex. Older builds will be deleted."""
max_flow_version_entries_per_flow: int = 50
"""Max version history entries per flow. Oldest entries pruned on next snapshot.
If retroactively lowered below the current count for a flow,
the oldest entries are deleted only when the next entry is created.
"""

View File

@ -0,0 +1,46 @@
from pathlib import Path
from typing import Any
from pydantic import BaseModel, field_validator
class PathSettings(BaseModel):
"""Filesystem paths Langflow reads from and writes to."""
config_dir: str | None = None
"""Base directory for Langflow data (db, logs, caches)."""
knowledge_bases_dir: str | None = "~/.langflow/knowledge_bases"
"""The directory to store knowledge bases."""
kb_allowed_folder_roots: list[str] = []
"""Allow-list of directories the folder-ingestion endpoint may read from.
Comma-separated when set via env (``LANGFLOW_KB_ALLOWED_FOLDER_ROOTS``),
e.g. ``/srv/docs,/data/shared``. Empty by default — operators must opt in.
``POST /api/v1/knowledge_bases/{kb_name}/ingest/folder`` refuses to walk any
directory that is not equal to or inside one of these roots; symlink escapes
are blocked because the path is resolved before the containment check. Leave
empty in multi-tenant cloud deployments to refuse arbitrary-path access."""
@field_validator("config_dir", mode="before")
@classmethod
def set_langflow_dir(cls, value: Any) -> str:
if not value:
from platformdirs import user_cache_dir
app_name = "langflow"
app_author = "langflow"
cache_dir = user_cache_dir(app_name, app_author)
value = Path(cache_dir)
value.mkdir(parents=True, exist_ok=True)
if isinstance(value, str):
value = Path(value)
value = value.resolve()
if not value.exists():
value.mkdir(parents=True, exist_ok=True)
return str(value)

View File

@ -0,0 +1,111 @@
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from lfx.log.logger import logger
class RuntimeSettings(BaseModel):
"""Runtime behaviors: event delivery, worker timeouts, polling intervals, public flows, misc toggles.
Note: ``event_delivery`` is validated here but reads ``workers`` from
:class:`ServerSettings`. The composition order in :class:`Settings`
guarantees ``workers`` is in ``info.data`` when this validator runs.
"""
dev: bool = False
"""If True, Langflow will run in development mode."""
# Job Queue
job_queue_type: Literal["asyncio", "redis"] = "asyncio"
"""The job queue backend. Use 'redis' for multi-worker deployments to solve cross-worker JobQueueNotFoundError."""
redis_queue_host: str | None = None
"""Redis host for the job queue. Falls back to redis_host if not set."""
redis_queue_port: int | None = None
"""Redis port for the job queue. Falls back to redis_port if not set."""
redis_queue_db: int = 1
"""Redis DB number for the job queue. Defaults to 1 to avoid conflict with the cache (DB 0)."""
redis_queue_url: str | None = None
"""Full Redis URL for the job queue. Takes priority over host/port/db if set."""
redis_queue_ttl: int = 3600
"""TTL in seconds for job stream keys in Redis."""
redis_queue_startup_grace_s: float = Field(default=30.0, ge=0)
"""Seconds a cross-worker consumer waits for the producer's first XADD before
treating a missing stream key as end-of-stream. Bump this if cold-start build
latency on the producer worker can exceed the default (e.g. large graph
instantiation, slow container image pulls). Negative values would make
consumers treat a not-yet-created stream as EOF immediately, so values must
be non-negative."""
redis_queue_cancel_channel_enabled: bool = True
"""If True, RedisJobQueueService runs a single PSUBSCRIBE dispatcher per worker
so POST /build/{job_id}/cancel works cross-worker. Any worker can publish a
cancel signal; the owning worker cancels the local build task."""
redis_queue_cancel_marker_ttl: int = Field(default=60, gt=0)
"""TTL in seconds for the persistent cancel-marker key used to close the race
where a cancel signal is published before the owning worker's dispatcher
subscribes or before the job is registered. Should comfortably exceed worker
cold-start latency. Must be > 0: a non-positive TTL makes the marker
ineffective and reopens the publish-before-subscribe race it closes."""
redis_queue_polling_stale_threshold_s: float = Field(default=90.0, ge=0)
"""Maximum seconds a polling job may go without client activity before the
watchdog publishes a cross-worker cancel. Polling clients have no persistent
connection, so the server detects abandonment by tracking the most recent
poll (or streaming-response heartbeat). Set to 0 to disable the watchdog."""
redis_queue_polling_watchdog_interval_s: float = Field(default=15.0, gt=0)
"""How often the polling watchdog scans owned jobs. Smaller values give
faster reclamation of abandoned builds at the cost of more Redis GETs.
The watchdog only checks jobs this worker owns (entries in self._queues).
Must be > 0 so the scan loop makes progress."""
event_delivery: Literal["polling", "streaming", "direct"] = "streaming"
"""How to deliver build events to the frontend. Can be 'polling', 'streaming' or 'direct'."""
worker_timeout: int = 300
"""Timeout for the API calls in seconds."""
public_flow_cleanup_interval: int = Field(default=3600, gt=600)
"""The interval in seconds at which public temporary flows will be cleaned up.
Default is 1 hour (3600 seconds). Minimum is 600 seconds (10 minutes)."""
public_flow_expiration: int = Field(default=86400, gt=600)
"""The time in seconds after which a public temporary flow will be considered expired and eligible for cleanup.
Default is 24 hours (86400 seconds). Minimum is 600 seconds (10 minutes)."""
webhook_polling_interval: int = 0
"""The polling interval for the webhook in ms. Set to 0 to disable (SSE provides real-time updates)."""
fs_flows_polling_interval: int = 10000
"""The polling interval in milliseconds for synchronizing flows from the file system."""
health_check_max_retries: int = 5
"""The maximum number of retries for the health check."""
max_file_size_upload: int = 1024
"""The maximum file size for the upload in MB."""
max_ingestion_timeout_secs: int = 600
celery_enabled: bool = False
@field_validator("event_delivery", mode="before")
@classmethod
def set_event_delivery(cls, value, info):
# Multi-worker deployments with the in-memory job queue cannot route
# ``polling`` or ``streaming`` responses correctly: build events live in
# the in-process queue of whichever worker started the job, and a later
# poll/stream request may land on a different worker. Switch to Redis
# (LANGFLOW_JOB_QUEUE_TYPE=redis) to share state across workers, or
# accept ``direct`` delivery which keeps the whole exchange on one
# worker. The override below preserves backwards compatibility for
# deployments that haven't set this explicitly; new explicit values are
# logged loudly so the cause is easy to diagnose if the UI loses events.
if info.data.get("workers", 1) > 1 and info.data.get("job_queue_type", "asyncio") != "redis":
requested = value or "polling"
if requested != "direct":
logger.warning(
"Multi-worker mode without a Redis-backed job queue cannot deliver "
"'%s' events across workers; forcing event_delivery='direct'. "
"Set LANGFLOW_JOB_QUEUE_TYPE=redis to keep '%s' delivery in multi-worker setups.",
requested,
requested,
)
return "direct"
return value

View File

@ -0,0 +1,90 @@
from pydantic import BaseModel, field_validator
class SecuritySettings(BaseModel):
"""CORS, SSRF protection, API key handling, and custom-component policy."""
# CORS
cors_origins: list[str] | str = "*"
"""Allowed origins for CORS. Can be a list of origins or '*' for all origins.
Default is '*' for backward compatibility. In production, specify exact origins."""
cors_allow_credentials: bool = True
"""Whether to allow credentials in CORS requests.
Default is True for backward compatibility. In v2.0, this will be changed to False when using wildcard origins."""
cors_allow_methods: list[str] | str = "*"
"""Allowed HTTP methods for CORS requests."""
cors_allow_headers: list[str] | str = "*"
"""Allowed headers for CORS requests."""
# SSRF Protection
ssrf_protection_enabled: bool = True
"""If set to True, Langflow will enable SSRF (Server-Side Request Forgery) protection.
When enabled, blocks requests to private IP ranges, localhost, and cloud metadata endpoints.
When False, no URL validation is performed, allowing requests to any destination
including internal services, private networks, and cloud metadata endpoints.
Default is True to protect against SSRF attacks including DNS rebinding.
Note: When ssrf_protection_enabled is disabled, the ssrf_allowed_hosts setting is ignored and has no effect."""
ssrf_allowed_hosts: list[str] = []
"""Comma-separated list of hosts/IPs/CIDR ranges to allow despite SSRF protection.
Examples: 'internal-api.company.local,192.168.1.0/24,10.0.0.5,*.dev.internal'
Supports exact hostnames, wildcard domains (*.example.com), exact IPs, and CIDR ranges.
Note: This setting only takes effect when ssrf_protection_enabled is True.
When protection is disabled, all hosts are allowed regardless of this setting."""
# API key handling
disable_track_apikey_usage: bool = False
remove_api_keys: bool = False
# Custom Component Security
allow_custom_components: bool = True
"""If set to False, blocks execution of components whose code does not match a known
server template.
The server validates node code against its component template cache;
when the cache is not yet loaded (e.g., during startup), all flow execution is blocked
as a safety measure.
Note: LANGFLOW_COMPONENTS_PATH and LANGFLOW_COMPONENTS_INDEX_PATH can be used to define
an allow-list of custom components that will be allowed to execute, even when
allow_custom_components is False. That bypass can be disabled with
allow_components_paths_override.
Note: this is a beta feature. For security in a multi-tenant environment,
use hardware-level isolation to restrict access."""
custom_component_admin_only: bool = False
"""If set to True, only admin users can edit custom component code. Regular editors
are blocked from modifying custom component templates."""
allow_components_paths_override: bool = True
"""If set to False, LANGFLOW_COMPONENTS_PATH and LANGFLOW_COMPONENTS_INDEX_PATH will
not bypass the allow_custom_components=False restriction — only components matching
built-in server templates will be executable.
Default is True, which preserves the existing behavior: components loaded from those
env-var paths act as an admin-curated allow-list that remains executable even when
allow_custom_components is False.
Has no effect when allow_custom_components is True (the flag is not blocking anything
to override)."""
# Rate Limiting
rate_limit_enabled: bool = True
"""Enable rate limiting for login endpoint. Set to False to disable (useful for testing)."""
rate_limit_per_minute: int = 5
"""Number of login attempts allowed per minute per IP."""
rate_limit_storage_uri: str = "memory://"
"""Storage backend for rate limiting. Use 'memory://' for single-server or 'redis://host:port' for multi-server."""
rate_limit_trust_proxy: bool = False
"""Trust X-Forwarded-For header when behind a reverse proxy. Only enable when behind a trusted proxy."""
@field_validator("cors_origins", mode="before")
@classmethod
def validate_cors_origins(cls, value):
"""Convert comma-separated string to list if needed."""
if isinstance(value, str) and value != "*":
if "," in value:
return [origin.strip() for origin in value.split(",")]
return [value]
return value

View File

@ -0,0 +1,111 @@
import os
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from pydantic import BaseModel, Field, field_validator
from lfx.log.logger import logger
class ServerSettings(BaseModel):
"""ASGI server, process, and logging settings."""
host: str = "localhost"
"""The host on which Langflow will run."""
port: int = 7860
"""The port on which Langflow will run."""
runtime_port: int | None = Field(default=None, exclude=True)
"""TEMPORARY: The port detected at runtime after checking for conflicts.
This field is system-managed only and will be removed in future versions
when strict port enforcement is implemented (errors will be raised if port unavailable)."""
workers: int = 1
"""The number of workers to run."""
log_level: str = "critical"
"""The log level for Langflow."""
log_file: str | None = "logs/langflow.log"
"""The path to log file for Langflow."""
alembic_log_file: str = "alembic/alembic.log"
"""The path to log file for Alembic for SQLAlchemy."""
alembic_log_to_stdout: bool = False
"""If set to True, the log file will be ignored and Alembic will log to stdout."""
frontend_path: str | None = None
"""The path to the frontend directory containing build files. This is for development purposes only."""
open_browser: bool = False
"""If set to True, Langflow will open the browser on startup."""
backend_only: bool = False
"""If set to True, Langflow will not serve the frontend."""
ssl_cert_file: str | None = None
"""Path to the SSL certificate file on the local system."""
ssl_key_file: str | None = None
"""Path to the SSL key file on the local system."""
root_path: str = ""
"""ASGI root_path for deployments behind a reverse proxy that strips a URL
prefix (e.g. '/langflow'). When set, the MCP SSE transport includes this
prefix in the POST-back URL so clients can reach the correct endpoint.
Can also be set via the LANGFLOW_ROOT_PATH environment variable."""
user_agent: str = "langflow"
"""User agent for the API calls."""
@field_validator("root_path", mode="before")
@classmethod
def validate_root_path(cls, value: Any) -> str:
if value is None:
return ""
if not isinstance(value, str):
msg = "root_path must be a string"
raise TypeError(msg)
value = value.strip()
if not value or value == "/":
return ""
if "://" in value or "?" in value or "#" in value:
msg = "root_path must be an ASGI path prefix only, without scheme, query string, or fragment"
raise ValueError(msg)
if not value.startswith("/"):
value = f"/{value}"
return value.rstrip("/")
@field_validator("runtime_port", mode="before")
@classmethod
def validate_runtime_port(cls, value):
"""Parse port from Kubernetes service discovery env vars.
Kubernetes auto-creates env vars like LANGFLOW_RUNTIME_PORT=tcp://<ip>:<port>
for services, which collides with the LANGFLOW_ env prefix. Extract the port
number from URL-like values instead of failing.
"""
if value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, str):
if value.isdigit():
return int(value)
if "://" in value:
try:
parsed_port = urlparse(value).port
except ValueError:
return None
if parsed_port is not None:
return parsed_port
return None
@field_validator("log_file", mode="before")
@classmethod
def set_log_file(cls, value):
if isinstance(value, Path):
value = str(value)
return value
@field_validator("user_agent", mode="after")
@classmethod
def set_user_agent(cls, value):
if not value:
value = "Langflow"
os.environ["USER_AGENT"] = value
logger.debug(f"Setting user agent to {value}")
return value

View File

@ -0,0 +1,14 @@
from pydantic import BaseModel
class StorageSettings(BaseModel):
"""File storage backend (local filesystem or object storage)."""
storage_type: str = "local"
"""Storage type for file storage. Defaults to 'local'. Supports 'local' and 's3'."""
object_storage_bucket_name: str | None = "langflow-bucket"
"""Object storage bucket name for file storage. Defaults to 'langflow-bucket'."""
object_storage_prefix: str | None = "files"
"""Object storage prefix for file storage. Defaults to 'files'."""
object_storage_tags: dict[str, str] | None = None
"""Object storage tags for file storage."""

View File

@ -0,0 +1,77 @@
from typing import Literal
from pydantic import BaseModel
class TelemetrySettings(BaseModel):
"""Telemetry, error tracking, and tracing settings."""
# Sentry
sentry_dsn: str | None = None
sentry_traces_sample_rate: float | None = 1.0
sentry_profiles_sample_rate: float | None = 1.0
# Telemetry
do_not_track: bool = False
"""If set to True, Langflow will not track telemetry."""
telemetry_base_url: str = "https://langflow.gateway.scarf.sh"
transactions_storage_enabled: bool = True
"""If set to True, Langflow will track transactions between flows."""
vertex_builds_storage_enabled: bool = True
"""If set to True, Langflow will keep track of each vertex builds (outputs) in the UI for any flow."""
telemetry_writer_enabled: bool = True
"""Route transaction and vertex_build writes through an async batched writer backed by a
disk-persisted outbox and a dedicated database connection. Eliminates SQLite
'database is locked' errors and Postgres pool-timeouts under heavy load by keeping
telemetry traffic off the request-handling connection pool.
Set to False to fall back to the legacy direct-write path."""
telemetry_writer_batch_size: int = 200
"""Maximum rows per batched INSERT executed by the telemetry writer."""
telemetry_writer_flush_interval_s: float = 0.5
"""Maximum seconds the writer waits before flushing a partial batch."""
telemetry_writer_cleanup_interval_s: int = 60
"""Cadence (seconds) of the retention sweeper that enforces max_transactions_to_keep
and max_vertex_builds_to_keep. Retention is amortized rather than per-row to avoid
inflating per-write cost."""
telemetry_writer_max_queue: int = 100_000
"""Per-outbox cap. When exceeded, oldest entries are dropped and a counter is
incremented. Bounds disk usage in pathological backlog scenarios."""
telemetry_writer_outbox_dir: str | None = None
"""Directory for the disk-backed outbox. Defaults to
<tempdir>/langflow_telemetry_outbox. Each worker process uses an isolated
subdirectory keyed by PID; sibling subdirectories from crashed workers are
automatically adopted on startup."""
telemetry_writer_shutdown_drain_s: float = 5.0
"""Maximum seconds the writer waits to drain in-flight batches on shutdown
before disposing the dedicated engine."""
telemetry_writer_orphan_max_age_s: float = 3600.0
"""Cross-host orphan outboxes (e.g. dead pods on a shared volume) are pruned
when their owner file hasn't been heartbeated within this many seconds.
Same-host orphans are adopted regardless of age via owner-file identity."""
telemetry_writer_size_strategy: Literal["count", "bytes", "either"] = "count"
"""How the writer bounds memory and flushes.
- 'count' (default): preserve legacy row-count semantics. ``batch_size`` and
``max_queue`` are the only thresholds.
- 'bytes': bound by the byte thresholds below; row caps are ignored.
- 'either': whichever (rows or bytes) trips first wins. Recommended for
deployments with variable payload sizes (large vertex_build artifacts,
doc-loader outputs)."""
telemetry_writer_batch_size_bytes: int = 262_144
"""Cap on per-flush batch size in *encoded JSON bytes*. Consulted when
``telemetry_writer_size_strategy`` is 'bytes' or 'either'. Sized around a
single TCP frame so individual INSERTs stay below DB packet limits.
Note: this measures the payload's serialized size, not Python's in-memory
footprint (dicts carry significant overhead beyond their JSON form)."""
telemetry_writer_max_queue_bytes: int = 209_715_200
"""Per-outbox cap in *encoded JSON bytes*. When exceeded under 'bytes' or
'either' strategy, oldest entries are dropped until the buffer fits.
Defaults to ~200MB so a single worker's telemetry buffer can't dominate
container memory. As with ``batch_size_bytes`` this is serialized size, not
Python in-memory size — actual RSS will be 2-5x higher."""
deactivate_tracing: bool = False
"""If set to True, tracing will be deactivated."""

View File

@ -0,0 +1,54 @@
from pydantic import AliasChoices, BaseModel, Field
from lfx.serialization.constants import MAX_ITEMS_LENGTH, MAX_TEXT_LENGTH
class UiSettings(BaseModel):
"""Frontend, auto-save, display limits, and (legacy) Langflow Store integration."""
auto_saving: bool = True
"""If set to True, Langflow will auto save flows."""
auto_saving_interval: int = 1000
"""The interval in ms at which Langflow will auto save flows."""
max_text_length: int = MAX_TEXT_LENGTH
"""Maximum number of characters to store and display in the UI. Responses longer than this
will be truncated when displayed in the UI. Does not truncate responses between components nor outputs."""
max_items_length: int = MAX_ITEMS_LENGTH
"""Maximum number of items to store and display in the UI. Lists longer than this
will be truncated when displayed in the UI. Does not affect data passed between components nor outputs."""
frontend_timeout: int = 0
"""Timeout for the frontend API calls in seconds."""
# Embedded mode flags
embedded_mode: bool = False
"""Umbrella flag for iframe/embedded mode. When True, hides UI elements specific to
standalone installations (logout button, new project/flow buttons, starter projects, etc.).
This flag does not implicitly enable security controls such as
``mcp_servers_locked`` or ``custom_component_admin_only``. Configure those
explicitly based on your deployment hardening requirements.
"""
hide_getting_started_progress: bool = Field(
default=False,
validation_alias=AliasChoices(
"LANGFLOW_HIDE_GETTING_STARTED_PROGRESS",
"HIDE_GETTING_STARTED_PROGRESS",
),
)
"""If set to True, hides the getting-started onboarding progress UI."""
hide_logout_button: bool = False
"""If set to True, hides the Logout button in the account menu."""
hide_new_project_button: bool = False
"""If set to True, hides the ability to create new projects/folders."""
hide_new_flow_button: bool = False
"""If set to True, hides the ability to create new flows."""
hide_starter_projects: bool = False
"""If set to True, hides starter projects from the UI (does not affect database seeding)."""
# Langflow Store (legacy)
store: bool | None = True
store_url: str | None = "https://api.langflow.store"
download_webhook_url: str | None = "https://api.langflow.store/flows/trigger/ec611a61-8460-4438-b187-a4f65e5559d4"
like_webhook_url: str | None = "https://api.langflow.store/flows/trigger/64275852-ec00-45c1-984e-3bff814732da"

View File

@ -0,0 +1,46 @@
import os
from pydantic import BaseModel, field_validator
from lfx.services.settings.constants import AGENTIC_VARIABLES, VARIABLES_TO_GET_FROM_ENVIRONMENT
class VariablesSettings(BaseModel):
"""Global variable store, environment-variable bridge, and experimental feature toggles."""
variable_store: str = "db"
"""The store can be 'db' or 'kubernetes'."""
fallback_to_env_var: bool = True
"""If set to True, Global Variables set in the UI will fallback to a environment variable
with the same name in case Langflow fails to retrieve the variable value."""
store_environment_variables: bool = True
"""Whether to store environment variables as Global Variables in the database."""
variables_to_get_from_environment: list[str] = VARIABLES_TO_GET_FROM_ENVIRONMENT
"""List of environment variables to get from the environment and store in the database."""
# Agentic Experience
agentic_experience: bool = False
"""If set to True, Langflow will start the agentic MCP server that provides tools for
flow/component operations, template search, and graph visualization."""
# Developer API
developer_api_enabled: bool = False
"""If set to True, Langflow will enable developer API endpoints for advanced debugging and introspection."""
@field_validator("variables_to_get_from_environment", mode="before")
@classmethod
def set_variables_to_get_from_environment(cls, value):
if isinstance(value, str):
value = value.split(",")
result = list(set(VARIABLES_TO_GET_FROM_ENVIRONMENT + value))
# Add agentic variables if agentic_experience is enabled
# Check env var directly since we can't access instance attributes in validator
if os.getenv("LANGFLOW_AGENTIC_EXPERIENCE", "true").lower() == "true":
result.extend(AGENTIC_VARIABLES)
return list(set(result))

View File

@ -29,10 +29,11 @@ def _capture_warnings(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Capture warnings the settings validator emits.
Settings uses ``lfx.log.logger.logger`` (a structlog BoundLogger that may
be filtered at the configured log level). Replace the module-level
``logger`` symbol in ``lfx.services.settings.base`` with a tiny stand-in
that records ``warning`` calls so the test inspects exactly the messages
the validator emits, regardless of structlog's filter level.
be filtered at the configured log level). ``set_event_delivery`` lives in
the ``RuntimeSettings`` mixin, so replace the module-level ``logger`` symbol
in ``lfx.services.settings.groups.runtime`` with a tiny stand-in that records
``warning`` calls so the test inspects exactly the messages the validator
emits, regardless of structlog's filter level.
"""
captured: list[str] = []
@ -43,9 +44,9 @@ def _capture_warnings(monkeypatch: pytest.MonkeyPatch) -> list[str]:
def __getattr__(self, _name: str): # pragma: no cover - swallow other levels
return lambda *_a, **_k: None
import lfx.services.settings.base as settings_base
import lfx.services.settings.groups.runtime as settings_runtime
monkeypatch.setattr(settings_base, "logger", _Recorder())
monkeypatch.setattr(settings_runtime, "logger", _Recorder())
return captured

View File

@ -0,0 +1,369 @@
"""Structural tests for the composed ``Settings`` class.
These guard the refactor that split ``Settings`` into per-group mixins:
- every field that used to live on ``Settings`` still does (catches an
accidental drop of a group from the inheritance list),
- a sampling of critical defaults is unchanged,
- cross-group validators still see their dependencies in ``info.data``
(workers -> event_delivery, config_dir -> database_url),
- yaml round-trip and the small utility helpers still work.
"""
import tempfile
from pathlib import Path
import pytest
from lfx.services.settings.base import (
BASE_COMPONENTS_PATH,
CustomSource,
Settings,
is_list_of_any,
load_settings_from_yaml,
save_settings_to_yaml,
)
# Every field the composed Settings must expose: the original monolith fields
# plus the settings added in 1.10.0 (folded into the mixins during the release
# back-merge). Asserted as a set so a missing group in the inheritance chain —
# or a dropped 1.10.0 field — trips the test loudly.
EXPECTED_FIELDS = {
# PathSettings
"config_dir",
"knowledge_bases_dir",
# ServerSettings
"host",
"port",
"runtime_port",
"workers",
"log_level",
"log_file",
"alembic_log_file",
"alembic_log_to_stdout",
"frontend_path",
"open_browser",
"backend_only",
"ssl_cert_file",
"ssl_key_file",
"root_path",
"user_agent",
# DatabaseSettings
"save_db_in_config_dir",
"database_url",
"database_connection_retry",
"pool_size",
"max_overflow",
"db_connect_timeout",
"migration_lock_namespace",
"sqlite_pragmas",
"db_driver_connection_settings",
"db_connection_settings",
"use_noop_database",
# CacheSettings
"cache_type",
"cache_expire",
"cache_dir",
"langchain_cache",
"redis_host",
"redis_port",
"redis_db",
"redis_url",
"redis_cache_expire",
# StorageSettings
"storage_type",
"object_storage_bucket_name",
"object_storage_prefix",
"object_storage_tags",
# McpSettings
"mcp_base_url",
"mcp_server_timeout",
"mcp_max_sessions_per_server",
"mcp_session_idle_timeout",
"mcp_session_cleanup_interval",
"mcp_server_enabled",
"mcp_server_enable_progress_notifications",
"add_projects_to_mcp_servers",
"mcp_composer_enabled",
"mcp_composer_version",
# TelemetrySettings
"sentry_dsn",
"sentry_traces_sample_rate",
"sentry_profiles_sample_rate",
"do_not_track",
"telemetry_base_url",
"transactions_storage_enabled",
"vertex_builds_storage_enabled",
"deactivate_tracing",
# ObservabilitySettings
"prometheus_enabled",
"prometheus_port",
"max_transactions_to_keep",
"max_vertex_builds_to_keep",
"max_vertex_builds_per_vertex",
"max_flow_version_entries_per_flow",
# SecuritySettings
"cors_origins",
"cors_allow_credentials",
"cors_allow_methods",
"cors_allow_headers",
"ssrf_protection_enabled",
"ssrf_allowed_hosts",
"disable_track_apikey_usage",
"remove_api_keys",
"allow_custom_components",
# ComponentsSettings
"components_path",
"components_index_path",
"load_flows_path",
"bundle_urls",
"lazy_load_components",
"create_starter_projects",
"update_starter_projects",
# UiSettings
"auto_saving",
"auto_saving_interval",
"max_text_length",
"max_items_length",
"frontend_timeout",
"store",
"store_url",
"download_webhook_url",
"like_webhook_url",
# RuntimeSettings
"dev",
"event_delivery",
"worker_timeout",
"public_flow_cleanup_interval",
"public_flow_expiration",
"webhook_polling_interval",
"fs_flows_polling_interval",
"health_check_max_retries",
"max_file_size_upload",
"celery_enabled",
# VariablesSettings
"variable_store",
"fallback_to_env_var",
"store_environment_variables",
"variables_to_get_from_environment",
"agentic_experience",
"developer_api_enabled",
# ---- Added in 1.10.0, folded into the mixins during the release back-merge ----
# PathSettings
"kb_allowed_folder_roots",
# McpSettings
"mcp_tool_execution_timeout",
"mcp_servers_locked",
# ComponentsSettings
"load_flows_overwrite_on_name_match",
"enable_extension_reload",
# SecuritySettings
"rate_limit_enabled",
"rate_limit_per_minute",
"rate_limit_storage_uri",
"rate_limit_trust_proxy",
"custom_component_admin_only",
"allow_components_paths_override",
# RuntimeSettings
"job_queue_type",
"redis_queue_host",
"redis_queue_port",
"redis_queue_db",
"redis_queue_url",
"redis_queue_ttl",
"redis_queue_startup_grace_s",
"redis_queue_cancel_channel_enabled",
"redis_queue_cancel_marker_ttl",
"redis_queue_polling_stale_threshold_s",
"redis_queue_polling_watchdog_interval_s",
"max_ingestion_timeout_secs",
# UiSettings
"embedded_mode",
"hide_getting_started_progress",
"hide_logout_button",
"hide_new_project_button",
"hide_new_flow_button",
"hide_starter_projects",
# TelemetrySettings
"telemetry_writer_enabled",
"telemetry_writer_batch_size",
"telemetry_writer_flush_interval_s",
"telemetry_writer_cleanup_interval_s",
"telemetry_writer_max_queue",
"telemetry_writer_outbox_dir",
"telemetry_writer_shutdown_drain_s",
"telemetry_writer_orphan_max_age_s",
"telemetry_writer_size_strategy",
"telemetry_writer_batch_size_bytes",
"telemetry_writer_max_queue_bytes",
}
def test_all_expected_fields_present():
"""Every field that lived on the monolithic Settings is still present.
Trips loudly if a group is dropped from the inheritance list.
"""
actual = set(Settings.model_fields)
missing = EXPECTED_FIELDS - actual
assert not missing, f"Settings is missing fields: {sorted(missing)}"
def test_field_count_unchanged():
"""The total field count matches the curated expected set (no stray adds/drops)."""
assert len(Settings.model_fields) == len(EXPECTED_FIELDS)
def test_critical_defaults_unchanged():
"""A sampling of important field defaults survives the split byte-for-byte."""
settings = Settings()
assert settings.host == "localhost"
assert settings.port == 7860
assert settings.workers == 1
assert settings.cache_type == "async"
assert settings.storage_type == "local"
assert settings.event_delivery == "streaming"
assert settings.cors_origins == "*"
assert settings.cors_allow_credentials is True
assert settings.ssrf_protection_enabled is True
assert settings.allow_custom_components is True
assert settings.mcp_server_enabled is True
assert settings.mcp_composer_enabled is True
assert settings.do_not_track is False
assert settings.dev is False
assert settings.agentic_experience is False
assert settings.developer_api_enabled is False
def test_dict_defaults_unchanged():
"""Dict-typed defaults like sqlite_pragmas and db_connection_settings are intact."""
settings = Settings()
assert settings.sqlite_pragmas == {
"synchronous": "NORMAL",
"journal_mode": "WAL",
"busy_timeout": 30000,
}
assert settings.db_connection_settings == {
"pool_size": 20,
"max_overflow": 30,
"pool_timeout": 30,
"pool_pre_ping": True,
"pool_recycle": 1800,
"echo": False,
}
def test_multi_worker_forces_direct_event_delivery(monkeypatch):
"""Workers > 1 must flip event_delivery to 'direct'.
Exercises the cross-group validator dependency: event_delivery lives in
RuntimeSettings, workers lives in ServerSettings, and the inheritance
order in Settings must ensure workers is in info.data when
event_delivery validates.
"""
monkeypatch.setenv("LANGFLOW_WORKERS", "4")
monkeypatch.setenv("LANGFLOW_EVENT_DELIVERY", "streaming")
settings = Settings()
assert settings.workers == 4
assert settings.event_delivery == "direct"
def test_single_worker_keeps_explicit_event_delivery(monkeypatch):
"""Workers == 1 leaves an explicit event_delivery setting alone."""
monkeypatch.setenv("LANGFLOW_WORKERS", "1")
monkeypatch.setenv("LANGFLOW_EVENT_DELIVERY", "polling")
settings = Settings()
assert settings.event_delivery == "polling"
def test_database_url_sees_config_dir(monkeypatch, tmp_path):
"""database_url validator must see config_dir in info.data.
With config_dir set and no LANGFLOW_DATABASE_URL env var, the validator
falls back to a sqlite path under the langflow package directory. If
PathSettings's config_dir wasn't validated first, the validator would
raise 'config_dir not set'.
"""
monkeypatch.delenv("LANGFLOW_DATABASE_URL", raising=False)
monkeypatch.setenv("LANGFLOW_CONFIG_DIR", str(tmp_path))
settings = Settings()
assert settings.database_url.startswith("sqlite:///")
assert settings.config_dir == str(tmp_path)
def test_back_compat_exports():
"""Symbols that consumers import from settings.base are still exported."""
assert is_list_of_any is not None
assert CustomSource is not None
assert save_settings_to_yaml is not None
assert load_settings_from_yaml is not None
assert isinstance(BASE_COMPONENTS_PATH, str)
def test_update_settings_scalar():
"""Settings.update_settings replaces scalar fields."""
settings = Settings()
settings.update_settings(port=9999)
assert settings.port == 9999
def test_update_settings_list_appends_unique():
"""Settings.update_settings appends to list fields without duplicates."""
settings = Settings()
before = list(settings.bundle_urls)
settings.update_settings(bundle_urls="https://example.com/bundle")
assert "https://example.com/bundle" in settings.bundle_urls
# Applying twice doesn't duplicate
settings.update_settings(bundle_urls="https://example.com/bundle")
assert settings.bundle_urls.count("https://example.com/bundle") == 1
# Original entries untouched
for url in before:
assert url in settings.bundle_urls
def test_yaml_round_trip():
"""save_settings_to_yaml + load_settings_from_yaml preserves field values."""
settings = Settings()
original_components = list(settings.components_path)
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f:
yaml_path = f.name
try:
save_settings_to_yaml(settings, yaml_path)
assert Path(yaml_path).exists()
# load_settings_from_yaml only reads components_path back today, but
# the helper should at least round-trip without erroring on a full dump.
# We don't assert deep equality because the yaml loader currently
# restricts to a subset of fields by design.
finally:
Path(yaml_path).unlink(missing_ok=True)
# components_path should be the same after re-instantiation with no env changes
settings2 = Settings()
assert settings2.components_path == original_components
@pytest.mark.parametrize(
("env_var", "env_value", "field", "expected"),
[
("LANGFLOW_HOST", "0.0.0.0", "host", "0.0.0.0"),
("LANGFLOW_PORT", "8080", "port", 8080),
("LANGFLOW_WORKERS", "2", "workers", 2),
("LANGFLOW_LOG_LEVEL", "info", "log_level", "info"),
("LANGFLOW_CACHE_TYPE", "memory", "cache_type", "memory"),
("LANGFLOW_STORAGE_TYPE", "s3", "storage_type", "s3"),
("LANGFLOW_PROMETHEUS_ENABLED", "true", "prometheus_enabled", True),
("LANGFLOW_PROMETHEUS_PORT", "9999", "prometheus_port", 9999),
("LANGFLOW_MCP_SERVER_ENABLED", "false", "mcp_server_enabled", False),
("LANGFLOW_DO_NOT_TRACK", "true", "do_not_track", True),
("LANGFLOW_DEV", "true", "dev", True),
("LANGFLOW_BACKEND_ONLY", "true", "backend_only", True),
("LANGFLOW_AUTO_SAVING", "false", "auto_saving", False),
("LANGFLOW_FALLBACK_TO_ENV_VAR", "false", "fallback_to_env_var", False),
("LANGFLOW_VARIABLE_STORE", "kubernetes", "variable_store", "kubernetes"),
],
)
def test_env_var_round_trip(monkeypatch, env_var, env_value, field, expected):
"""A sampling of LANGFLOW_* env vars still populate the right fields."""
monkeypatch.setenv(env_var, env_value)
settings = Settings()
assert getattr(settings, field) == expected