mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 00:39:35 +08:00
Merge branch 'main' into worktree-lfx-identity-forwarding-jwt
This commit is contained in:
@ -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
|
||||
@ -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.
|
||||
@ -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
|
||||
|
||||
@ -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 })),
|
||||
```
|
||||
|
||||
---
|
||||
@ -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"]
|
||||
```
|
||||
|
||||
|
||||
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
31
.github/workflows/db-migration-validation.yml
vendored
31
.github/workflows/db-migration-validation.yml
vendored
@ -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
|
||||
|
||||
8
.github/workflows/docker-nightly-build.yml
vendored
8
.github/workflows/docker-nightly-build.yml
vendored
@ -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
|
||||
|
||||
10
.github/workflows/nightly_build.yml
vendored
10
.github/workflows/nightly_build.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/python_test.yml
vendored
2
.github/workflows/python_test.yml
vendored
@ -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"
|
||||
|
||||
|
||||
5
.github/workflows/release-lfx.yml
vendored
5
.github/workflows/release-lfx.yml
vendored
@ -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
|
||||
|
||||
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
@ -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
|
||||
|
||||
118
.github/workflows/release_nightly.yml
vendored
118
.github/workflows/release_nightly.yml
vendored
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
79
docs/agents/ANTI-PATTERNS.md
Normal file
79
docs/agents/ANTI-PATTERNS.md
Normal 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.
|
||||
81
docs/agents/ARCHITECTURE.md
Normal file
81
docs/agents/ARCHITECTURE.md
Normal 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
138
docs/agents/COMPONENTS.md
Normal 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
57
docs/agents/CONTRACTS.md
Normal 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
33
docs/agents/PHILOSOPHY.md
Normal 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
163
docs/agents/TESTING.md
Normal 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.
|
||||
@ -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())
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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):
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
135
src/bundles/NIGHTLY.md
Normal 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.
|
||||
@ -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",
|
||||
]
|
||||
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
39
src/lfx/src/lfx/services/settings/groups/__init__.py
Normal file
39
src/lfx/src/lfx/services/settings/groups/__init__.py
Normal 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",
|
||||
]
|
||||
43
src/lfx/src/lfx/services/settings/groups/cache.py
Normal file
43
src/lfx/src/lfx/services/settings/groups/cache.py
Normal 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)
|
||||
154
src/lfx/src/lfx/services/settings/groups/components.py
Normal file
154
src/lfx/src/lfx/services/settings/groups/components.py
Normal 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
|
||||
157
src/lfx/src/lfx/services/settings/groups/database.py
Normal file
157
src/lfx/src/lfx/services/settings/groups/database.py
Normal 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
|
||||
94
src/lfx/src/lfx/services/settings/groups/mcp.py
Normal file
94
src/lfx/src/lfx/services/settings/groups/mcp.py
Normal 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
|
||||
23
src/lfx/src/lfx/services/settings/groups/observability.py
Normal file
23
src/lfx/src/lfx/services/settings/groups/observability.py
Normal 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.
|
||||
"""
|
||||
46
src/lfx/src/lfx/services/settings/groups/paths.py
Normal file
46
src/lfx/src/lfx/services/settings/groups/paths.py
Normal 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)
|
||||
111
src/lfx/src/lfx/services/settings/groups/runtime.py
Normal file
111
src/lfx/src/lfx/services/settings/groups/runtime.py
Normal 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
|
||||
90
src/lfx/src/lfx/services/settings/groups/security.py
Normal file
90
src/lfx/src/lfx/services/settings/groups/security.py
Normal 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
|
||||
111
src/lfx/src/lfx/services/settings/groups/server.py
Normal file
111
src/lfx/src/lfx/services/settings/groups/server.py
Normal 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
|
||||
14
src/lfx/src/lfx/services/settings/groups/storage.py
Normal file
14
src/lfx/src/lfx/services/settings/groups/storage.py
Normal 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."""
|
||||
77
src/lfx/src/lfx/services/settings/groups/telemetry.py
Normal file
77
src/lfx/src/lfx/services/settings/groups/telemetry.py
Normal 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."""
|
||||
54
src/lfx/src/lfx/services/settings/groups/ui.py
Normal file
54
src/lfx/src/lfx/services/settings/groups/ui.py
Normal 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"
|
||||
46
src/lfx/src/lfx/services/settings/groups/variables.py
Normal file
46
src/lfx/src/lfx/services/settings/groups/variables.py
Normal 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))
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
Reference in New Issue
Block a user