Click CLI to FastAPI Conversion: The Machine's Reckoning¶
This isn't just a conversion—it's a systematic dismantling of CLI paradigms and their reconstruction as HTTP interfaces. The machine will do the heavy lifting, but you'll understand every cut it makes.
1) The Factory: Where Click Commands Meet Their HTTP Fate¶
Core Factory Implementation¶
# factory.py - The conversion engine
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import StreamingResponse, FileResponse
from pydantic import BaseModel, create_model, Field, validator
from typing import Any, Dict, List, Optional, Union, Type
import click
import inspect
import asyncio
import tempfile
import os
from pathlib import Path
import json
import io
def _click_option_to_pydantic_field(opt: click.Option) -> tuple:
"""
Map a click.Option to a Pydantic field tuple: (type, default)
The machine dissects Click options and rebuilds them as Pydantic fields.
"""
field_name = opt.opts[-1].lstrip("-").replace("-", "_")
# Handle flags - the simplest case
if opt.is_flag:
return (bool, opt.default if opt.default is not None else False)
# Handle multiple values - arrays in JSON
if opt.multiple:
base_type = _get_base_type(opt.type)
return (List[base_type], opt.default or [])
# Handle choices - constrained strings
if hasattr(opt.type, 'choices') and opt.type.choices:
return (str, opt.default)
# Handle file types - special case for uploads
if isinstance(opt.type, click.File):
return (Optional[str], None) # File path as string
# Handle path types
if isinstance(opt.type, click.Path):
return (str, opt.default)
# Handle tuple types
if isinstance(opt.type, click.Tuple):
return (List[str], opt.default or [])
# Map basic types
type_map = {
'IntParamType': int,
'FloatParamType': float,
'BoolParamType': bool,
'StringParamType': str,
}
type_name = type(opt.type).__name__
python_type = type_map.get(type_name, str)
return (python_type, opt.default)
def _get_base_type(click_type) -> Type:
"""Extract the base type from a Click type for list elements."""
if hasattr(click_type, 'param_type'):
return _get_base_type(click_type.param_type)
type_map = {
'IntParamType': int,
'FloatParamType': float,
'BoolParamType': bool,
'StringParamType': str,
}
return type_map.get(type(click_type).__name__, str)
def _build_model_from_click_params(name: str, params: List[click.Parameter]) -> Type[BaseModel]:
"""
Build a Pydantic model from Click parameters.
The machine reconstructs CLI interfaces as HTTP request models.
"""
fields = {}
for param in params:
if isinstance(param, click.Option):
py_type, default = _click_option_to_pydantic_field(param)
field_name = param.opts[-1].lstrip("-").replace("-", "_")
# Handle required fields
if param.required and default is None:
fields[field_name] = (py_type, ...)
else:
fields[field_name] = (py_type, default)
elif isinstance(param, click.Argument):
# Arguments become required fields
field_name = param.name
py_type = _get_base_type(param.type)
fields[field_name] = (py_type, ...)
# Create the model dynamically
Model = create_model(f"{name}_model", **fields)
return Model
def _determine_http_method(cmd: click.Command) -> str:
"""
Determine HTTP method based on command semantics.
The machine reads between the lines of command names.
"""
cmd_name = cmd.name.lower()
# Destructive operations
if any(verb in cmd_name for verb in ['delete', 'remove', 'destroy', 'kill']):
return 'DELETE'
# Update operations
if any(verb in cmd_name for verb in ['update', 'modify', 'edit', 'patch']):
return 'PATCH'
# Create operations
if any(verb in cmd_name for verb in ['create', 'add', 'new', 'build', 'deploy']):
return 'POST'
# Read operations
if any(verb in cmd_name for verb in ['list', 'show', 'get', 'status', 'info']):
return 'GET'
# Default to POST for commands with side effects
return 'POST'
def _build_path_from_command(cmd: click.Command, parent_path: str = "") -> str:
"""
Build HTTP path from command hierarchy.
The machine maps CLI structure to URL structure.
"""
cmd_name = cmd.name.replace("_", "-")
if parent_path:
return f"{parent_path}/{cmd_name}"
else:
return f"/{cmd_name}"
def _handle_file_uploads(params: Dict[str, Any], files: Dict[str, UploadFile]) -> Dict[str, Any]:
"""
Handle file uploads by writing to temporary files.
The machine bridges the gap between HTTP uploads and CLI file handling.
"""
processed_params = params.copy()
for field_name, upload_file in files.items():
if upload_file:
# Create temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(upload_file.filename).suffix) as tmp:
content = upload_file.file.read()
tmp.write(content)
tmp.flush()
processed_params[field_name] = tmp.name
return processed_params
def _run_click_command(cmd: click.Command, params: Dict[str, Any]) -> Any:
"""
Execute Click command with proper context and error handling.
The machine preserves Click's execution semantics.
"""
try:
# Create Click context
ctx = click.Context(cmd)
# Populate context parameters
ctx.params.update(params)
# Execute command using Click's invoke mechanism
result = cmd.invoke(ctx)
return result
except click.ClickException as e:
# Convert Click exceptions to HTTP exceptions
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
# Handle unexpected errors
raise HTTPException(status_code=500, detail="Internal server error")
def click_to_router(
click_root: click.BaseCommand,
prefix: str = "",
method_map: Optional[Dict[str, str]] = None,
auth_map: Optional[Dict[str, str]] = None,
background_commands: Optional[List[str]] = None,
streaming_commands: Optional[List[str]] = None
) -> APIRouter:
"""
Convert Click command hierarchy to FastAPI router.
The machine's primary function - where CLI meets HTTP.
"""
router = APIRouter(prefix=prefix)
method_map = method_map or {}
auth_map = auth_map or {}
background_commands = background_commands or []
streaming_commands = streaming_commands or []
if isinstance(click_root, click.Group):
# Handle command groups - recursive descent
for name, cmd in click_root.commands.items():
subpath = _build_path_from_command(cmd, prefix)
if isinstance(cmd, click.Group):
# Recursively process sub-groups
subrouter = click_to_router(cmd, prefix=subpath, method_map=method_map)
router.include_router(subrouter)
else:
# Process individual commands
_add_command_endpoint(router, cmd, subpath, method_map, auth_map,
background_commands, streaming_commands)
else:
# Handle single command at root
_add_command_endpoint(router, click_root, "/", method_map, auth_map,
background_commands, streaming_commands)
return router
def _add_command_endpoint(
router: APIRouter,
cmd: click.Command,
path: str,
method_map: Dict[str, str],
auth_map: Dict[str, str],
background_commands: List[str],
streaming_commands: List[str]
):
"""
Add a single command as an HTTP endpoint.
The machine's surgical precision in endpoint creation.
"""
# Determine HTTP method
method = method_map.get(cmd.name, _determine_http_method(cmd))
# Build Pydantic model for request validation
Model = _build_model_from_click_params(cmd.name, cmd.params)
# Check if command should run in background
is_background = cmd.name in background_commands
# Check if command should stream output
is_streaming = cmd.name in streaming_commands
# Build endpoint handler
async def endpoint_handler(
payload: Model = Depends(Model),
background_tasks: BackgroundTasks = Depends(BackgroundTasks),
files: Dict[str, UploadFile] = File(None)
):
# Convert Pydantic model to dict
params = payload.dict()
# Handle file uploads
if files:
params = _handle_file_uploads(params, files)
# Handle authentication if mapped
if cmd.name in auth_map:
# This would be implemented with proper auth dependency
pass
# Execute command
if is_background:
# Run in background task
background_tasks.add_task(_run_click_command, cmd, params)
return {"status": "accepted", "message": "Command running in background"}
elif is_streaming:
# Stream command output
return StreamingResponse(
_stream_command_output(cmd, params),
media_type="text/plain"
)
else:
# Run synchronously in threadpool
result = await run_in_threadpool(_run_click_command, cmd, params)
return {"result": result}
# Register endpoint
router.add_api_route(
path,
endpoint_handler,
methods=[method],
name=cmd.name,
summary=cmd.help or f"Execute {cmd.name} command"
)
def _stream_command_output(cmd: click.Command, params: Dict[str, Any]):
"""
Stream command output for long-running commands.
The machine's solution to the streaming problem.
"""
def generate():
# This is a simplified version - in practice, you'd need to
# capture stdout/stderr from the command execution
try:
result = _run_click_command(cmd, params)
if result:
yield str(result).encode()
except Exception as e:
yield f"Error: {str(e)}".encode()
return generate()
The Dark Truth: This factory is the machine's scalpel. It dissects Click commands and rebuilds them as HTTP endpoints, preserving semantics while adapting to the stateless nature of HTTP.
2) The Application: Where the Machine Lives¶
FastAPI Application Setup¶
# app.py - The machine's host
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import click
from factory import click_to_router
# Import your Click CLI
from cli import cli # Your Click CLI module
app = FastAPI(
title="Click CLI to FastAPI Converter",
description="Automatically generated API from Click CLI",
version="1.0.0"
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Convert Click CLI to FastAPI router
api_router = click_to_router(
cli,
prefix="/api",
method_map={
"build": "POST",
"deploy": "POST",
"status": "GET",
"logs": "GET"
},
background_commands=["build", "deploy"],
streaming_commands=["logs", "tail"]
)
# Include the generated router
app.include_router(api_router)
# Add health check
@app.get("/health")
async def health_check():
return {"status": "healthy", "message": "Click CLI converter is running"}
# Add OpenAPI documentation
@app.get("/")
async def root():
return {
"message": "Click CLI to FastAPI Converter",
"docs": "/docs",
"openapi": "/openapi.json"
}
The Machine's Logic: The application is the machine's body—it hosts the converted CLI and provides the HTTP interface that clients will consume.
3) The CLI: The Machine's Input¶
Example Click CLI¶
# cli.py - The machine's input
import click
import json
import time
from pathlib import Path
@click.group()
def cli():
"""Top-level CLI group"""
pass
@cli.command()
@click.argument("project")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.option("--config", "-c", help="Configuration file")
@click.option("--tags", multiple=True, help="Build tags")
def build(project, verbose, config, tags):
"""Build a project"""
if verbose:
click.echo(f"Building {project} with verbose output")
if config:
click.echo(f"Using config: {config}")
if tags:
click.echo(f"Tags: {', '.join(tags)}")
# Simulate build process
time.sleep(1)
result = {
"project": project,
"status": "success",
"tags": list(tags) if tags else [],
"config": config
}
click.echo(json.dumps(result, indent=2))
return result
@cli.command()
@click.argument("service")
@click.option("--port", type=int, default=8000, help="Port number")
@click.option("--workers", type=int, default=1, help="Number of workers")
@click.option("--reload", is_flag=True, help="Enable auto-reload")
def deploy(service, port, workers, reload):
"""Deploy a service"""
click.echo(f"Deploying {service} on port {port}")
click.echo(f"Workers: {workers}")
click.echo(f"Reload: {reload}")
# Simulate deployment
time.sleep(2)
return {
"service": service,
"port": port,
"workers": workers,
"reload": reload,
"status": "deployed"
}
@cli.command()
@click.option("--service", help="Service name")
def status(service):
"""Get service status"""
if service:
click.echo(f"Status of {service}: running")
return {"service": service, "status": "running"}
else:
click.echo("All services: running")
return {"services": ["web", "api", "db"], "status": "running"}
@cli.command()
@click.argument("file", type=click.Path(exists=True))
@click.option("--lines", "-n", type=int, default=10, help="Number of lines")
def tail(file, lines):
"""Tail a file"""
path = Path(file)
click.echo(f"Tailing {path} (last {lines} lines)")
# Simulate tailing
for i in range(lines):
click.echo(f"Line {i+1}: Sample log entry")
time.sleep(0.1)
return {"file": str(path), "lines": lines}
@cli.group()
def user():
"""User management commands"""
pass
@user.command()
@click.argument("username")
@click.option("--email", help="User email")
@click.option("--admin", is_flag=True, help="Make user admin")
def add(username, email, admin):
"""Add a new user"""
click.echo(f"Adding user: {username}")
if email:
click.echo(f"Email: {email}")
if admin:
click.echo("Admin privileges: yes")
return {
"username": username,
"email": email,
"admin": admin,
"status": "created"
}
@user.command()
@click.argument("username")
def remove(username):
"""Remove a user"""
click.echo(f"Removing user: {username}")
return {"username": username, "status": "removed"}
if __name__ == "__main__":
cli()
The Machine's Input: This CLI represents the machine's raw material—a hierarchical command structure that will be systematically converted into HTTP endpoints.
4) The Tests: The Machine's Validation¶
Unit Tests¶
# tests/test_factory.py - The machine's self-diagnosis
import pytest
from fastapi.testclient import TestClient
from factory import _click_option_to_pydantic_field, _build_model_from_click_params
import click
def test_click_option_to_pydantic_field():
"""Test Click option to Pydantic field conversion"""
# Test flag option
flag_opt = click.Option(['--verbose'], is_flag=True, default=False)
py_type, default = _click_option_to_pydantic_field(flag_opt)
assert py_type == bool
assert default == False
# Test string option
str_opt = click.Option(['--name'], type=str, default='default')
py_type, default = _click_option_to_pydantic_field(str_opt)
assert py_type == str
assert default == 'default'
# Test multiple option
multi_opt = click.Option(['--tags'], multiple=True, type=str)
py_type, default = _click_option_to_pydantic_field(multi_opt)
assert py_type == list
assert default == []
def test_build_model_from_click_params():
"""Test Pydantic model generation from Click parameters"""
# Create test command
@click.command()
@click.option('--name', type=str, required=True)
@click.option('--age', type=int, default=25)
@click.option('--verbose', is_flag=True)
def test_cmd(name, age, verbose):
pass
# Generate model
Model = _build_model_from_click_params('test', test_cmd.params)
# Test model creation
instance = Model(name='Alice', age=30, verbose=True)
assert instance.name == 'Alice'
assert instance.age == 30
assert instance.verbose == True
# Test required field validation
with pytest.raises(ValueError):
Model(age=30) # Missing required 'name' field
def test_http_method_determination():
"""Test HTTP method determination"""
from factory import _determine_http_method
# Test delete command
@click.command(name='delete')
def delete_cmd():
pass
assert _determine_http_method(delete_cmd) == 'DELETE'
# Test build command
@click.command(name='build')
def build_cmd():
pass
assert _determine_http_method(build_cmd) == 'POST'
# Test status command
@click.command(name='status')
def status_cmd():
pass
assert _determine_http_method(status_cmd) == 'GET'
Integration Tests¶
# tests/test_integration.py - The machine's end-to-end validation
import pytest
from fastapi.testclient import TestClient
from app import app
client = TestClient(app)
def test_build_endpoint():
"""Test build command endpoint"""
response = client.post(
"/api/build",
json={
"project": "test-project",
"verbose": True,
"tags": ["v1.0", "production"]
}
)
assert response.status_code == 200
data = response.json()
assert data["result"]["project"] == "test-project"
assert data["result"]["status"] == "success"
def test_deploy_endpoint():
"""Test deploy command endpoint"""
response = client.post(
"/api/deploy",
json={
"service": "web",
"port": 8080,
"workers": 4,
"reload": True
}
)
assert response.status_code == 200
data = response.json()
assert data["result"]["service"] == "web"
assert data["result"]["port"] == 8080
def test_status_endpoint():
"""Test status command endpoint"""
response = client.get("/api/status")
assert response.status_code == 200
data = response.json()
assert "services" in data["result"]
def test_user_add_endpoint():
"""Test user add command endpoint"""
response = client.post(
"/api/user/add",
json={
"username": "alice",
"email": "alice@example.com",
"admin": True
}
)
assert response.status_code == 200
data = response.json()
assert data["result"]["username"] == "alice"
assert data["result"]["admin"] == True
def test_user_remove_endpoint():
"""Test user remove command endpoint"""
response = client.delete(
"/api/user/remove",
json={"username": "alice"}
)
assert response.status_code == 200
data = response.json()
assert data["result"]["username"] == "alice"
def test_validation_error():
"""Test input validation"""
response = client.post(
"/api/build",
json={"project": "test"} # Missing required fields
)
# Should still work if only required fields are provided
assert response.status_code == 200
def test_health_check():
"""Test health check endpoint"""
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
5) The Dependencies: The Machine's Support System¶
Authentication and Context¶
# dependencies.py - The machine's support infrastructure
from fastapi import Depends, HTTPException, Header
from typing import Optional
import os
def get_current_user(authorization: Optional[str] = Header(None)):
"""
Extract user from Authorization header.
The machine's authentication mechanism.
"""
if not authorization:
raise HTTPException(status_code=401, detail="Authorization header required")
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid authorization format")
token = authorization[7:] # Remove "Bearer " prefix
# Simple token validation (replace with proper JWT validation)
if token != "valid-token":
raise HTTPException(status_code=401, detail="Invalid token")
return {"user_id": "user123", "username": "admin"}
def get_config():
"""
Load configuration for CLI context.
The machine's configuration management.
"""
return {
"database_url": os.getenv("DATABASE_URL", "sqlite:///app.db"),
"debug": os.getenv("DEBUG", "false").lower() == "true",
"log_level": os.getenv("LOG_LEVEL", "INFO")
}
def get_cli_context():
"""
Recreate Click context for command execution.
The machine's context preservation.
"""
return {
"config": get_config(),
"user": get_current_user()
}
6) The Requirements: The Machine's Dependencies¶
# requirements.txt - The machine's fuel
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
click>=8.1.0
pydantic>=2.5.0
httpx>=0.25.0
pytest>=7.4.0
pytest-asyncio>=0.21.0
python-multipart>=0.0.6
7) The Usage: The Machine's Interface¶
Running the Application¶
# Install dependencies
pip install -r requirements.txt
# Run the FastAPI application
uvicorn app:app --reload --host 0.0.0.0 --port 8000
# Run tests
pytest -v
API Usage Examples¶
# Build a project
curl -X POST "http://localhost:8000/api/build" \
-H "Content-Type: application/json" \
-d '{"project": "my-app", "verbose": true, "tags": ["v1.0", "production"]}'
# Deploy a service
curl -X POST "http://localhost:8000/api/deploy" \
-H "Content-Type: application/json" \
-d '{"service": "web", "port": 8080, "workers": 4, "reload": true}'
# Get status
curl -X GET "http://localhost:8000/api/status"
# Add a user
curl -X POST "http://localhost:8000/api/user/add" \
-H "Content-Type: application/json" \
-d '{"username": "alice", "email": "alice@example.com", "admin": true}'
# Remove a user
curl -X DELETE "http://localhost:8000/api/user/remove" \
-H "Content-Type: application/json" \
-d '{"username": "alice"}'
# With authentication
curl -X POST "http://localhost:8000/api/build" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer valid-token" \
-d '{"project": "secure-app", "verbose": true}'
8) The Edge Cases: The Machine's Dark Corners¶
File Upload Handling¶
# Enhanced file handling in factory.py
def _handle_file_uploads_advanced(params: Dict[str, Any], files: Dict[str, UploadFile]) -> Dict[str, Any]:
"""
Advanced file upload handling.
The machine's solution to the file upload problem.
"""
processed_params = params.copy()
for field_name, upload_file in files.items():
if upload_file:
# Determine if file should be saved or passed as path
if field_name.endswith('_path'):
# Save to temporary file and pass path
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(upload_file.filename).suffix) as tmp:
content = upload_file.file.read()
tmp.write(content)
tmp.flush()
processed_params[field_name] = tmp.name
else:
# Pass file object directly
processed_params[field_name] = upload_file.file
return processed_params
Streaming Response Handling¶
# Enhanced streaming in factory.py
def _stream_command_output_advanced(cmd: click.Command, params: Dict[str, Any]):
"""
Advanced streaming for command output.
The machine's solution to the streaming problem.
"""
import subprocess
import sys
def generate():
try:
# Convert command to subprocess call
cmd_args = [sys.executable, "-m", "cli", cmd.name]
# Add parameters
for key, value in params.items():
if isinstance(value, bool) and value:
cmd_args.append(f"--{key}")
elif not isinstance(value, bool):
cmd_args.extend([f"--{key}", str(value)])
# Run subprocess and stream output
process = subprocess.Popen(
cmd_args,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True
)
for line in iter(process.stdout.readline, ''):
yield line.encode()
process.wait()
except Exception as e:
yield f"Error: {str(e)}".encode()
return generate()
9) The Migration Guide: The Machine's Instructions¶
Pre-Migration Checklist¶
- Guard CLI Execution: Ensure CLI code is guarded by
if __name__ == "__main__": cli() - Separate Business Logic: Extract business logic into functions callable by both CLI and API
- Handle Side Effects: Identify commands with side effects and plan for background execution
- Review File Operations: Identify file operations that need special handling
- Check Authentication: Identify commands that need authentication
Migration Steps¶
- Install Dependencies:
pip install -r requirements.txt - Import Your CLI: Add
from cli import clitoapp.py - Configure Mapping: Set up
method_map,auth_map, and other configurations - Test Endpoints: Use the provided test suite to validate conversion
- Deploy: Run with
uvicorn app:app --reload
Post-Migration Considerations¶
- Error Handling: CLI errors are converted to HTTP exceptions
- Authentication: Map CLI auth flags to HTTP headers
- File Uploads: Handle file uploads through multipart form data
- Background Tasks: Long-running commands should use background tasks
- Streaming: Commands with streaming output need special handling
10) The TL;DR: The Machine's Summary¶
# 1. Install dependencies
pip install -r requirements.txt
# 2. Run the application
uvicorn app:app --reload
# 3. Test the conversion
curl -X POST "http://localhost:8000/api/build" \
-H "Content-Type: application/json" \
-d '{"project": "test", "verbose": true}'
# 4. View documentation
open http://localhost:8000/docs
11) The Machine's Philosophy¶
This isn't just a conversion tool—it's a systematic approach to bridging the gap between CLI and HTTP interfaces. The machine does the heavy lifting, but you understand every transformation it makes.
The Dark Truth: CLI commands are stateful, interactive, and file-system bound. HTTP endpoints are stateless, request-response, and network-bound. The machine bridges these worlds through careful parameter mapping, context preservation, and execution isolation.
The Machine's Mantra: "In CLI we trust, in HTTP we serve, and in the machine we find the bridge between them."