Initial commit

This commit is contained in:
Leo Vasanko
2026-01-17 02:21:57 +00:00
commit a28c4ec149
11 changed files with 1475 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.*
!.gitignore
*.lock
__pycache__/
dist/
+124
View File
@@ -0,0 +1,124 @@
# fastapi-vue-setup
Tool to create or patch FastAPI project with a Vue frontend, with integrated build and development systems. Only building the package needs JS runtime and Vue. Additionally, a devmode setup using Vite dev server with hot auto reloads is available via the scripts/devserver.py script (only intended to be used on the source repo, not included in installed package).
## Features
- **No JavaScript**: Your Python package can be installed and used without any JS runtime
- **Create new projects** with `uv init` + `create-vue` (interactive)
- **Patch existing projects** with build hooks and dev server scripts
- **Integrated build system**: Vue frontend builds into Python package during `uv build`
- **Development server**: Single command runs Vite + FastAPI with hot-reload
- **Optimized static serving**: zstd compression, ETag caching, SPA support
## Installation
Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then:
```sh
uv tool install fastapi-vue-setup
fastapi-vue-setup --help
```
Or run directly:
```sh
uvx fastapi-vue-setup my-app
```
## Usage
### Create a new project
```sh
fastapi-vue-setup new my-app
```
This will:
1. Run `uv init my-app`
2. Run `npm create vue@latest frontend` (interactive)
3. Patch the project with FastAPI integration
### Patch an existing project
```bash
fastapi-vue-setup patch /path/to/project
```
Options:
- `--module-name NAME`: Python module name (auto-detected from pyproject.toml)
- `--vite-port PORT`: Vite dev server port (default: 5173)
- `--backend-port PORT`: Backend API port in dev mode (default: 5174)
- `--prod-port PORT`: Production server port (default: 8000)
- `--force`: Overwrite existing files
- `--dry-run`: Preview changes without modifying files
### Update an existing project
```bash
fastapi-vue-setup update /path/to/project
```
Same as `patch --force` - overwrites template files with latest versions.
## Port Configuration
The tool uses three distinct ports:
| Port | Purpose | Used by |
| ---- | --------------------- | ------------------------------ |
| 5173 | Vite dev server (HMR) | `npm run dev` via devserver.py |
| 5174 | FastAPI in dev mode | uvicorn via devserver.py |
| 8000 | Production server | `uv run my-app` |
In development, you access the app at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5174.
In production, FastAPI serves both the API and static files from the same port (8000).
## Project Structure
After patching, your project will have:
```
my-app/
├── frontend/ # Vue.js application
│ ├── src/
│ ├── vite.config.ts # Builds to ../my_app/frontend-build
│ └── package.json
├── my_app/ # Python package
│ ├── __init__.py
│ ├── __main__.py # CLI entry point
│ ├── app.py # FastAPI application
│ └── frontend-build/ # Built frontend (gitignored)
├── scripts/
│ ├── devserver.py # Development server
│ └── fastapi-vue/ # Build utilities
└── pyproject.toml # Project configuration
```
The script finds your existing fastpi app module (even if not named app.py) and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection.
## Development Workflow
```bash
# Start dev server (runs both Vite and FastAPI)
uv run scripts/devserver.py
# Build for production
uv build
# Run production server
uv run my-app
```
## Static File Serving
The `Frontend` class provides:
- **Automatic zstd compression** at level 18
- **Smart caching**: Content-hashed assets get `immutable` cache headers
- **ETag support**: 304 Not Modified responses for cached content
- **SPA routing**: Falls back to index.html for client-side routes
- **Favicon handling**: Automatic /favicon.ico from hashed assets
+817
View File
@@ -0,0 +1,817 @@
"""FastAPI-Vue Integration Tool
Create new FastAPI+Vue projects or patch existing ones with integrated build/dev systems.
Usage:
fastapi-vue-setup [project-dir] Set up or update FastAPI+Vue integration
Options:
--module-name NAME Python module name (auto-detected from pyproject.toml)
--vite-port PORT Vite dev server port (default: 5173)
--backend-port PORT Backend API port in dev mode (default: 5180)
--prod-port PORT Production server port (default: 5080)
--dry-run Show what would be done without making changes
Port options update existing files when specified, allowing reconfiguration.
"""
import argparse
import os
import re
import subprocess
import sys
import tomllib
from pathlib import Path
import tomli_w
# Port configuration (keeping them distinct)
DEFAULT_VITE_PORT = 5173 # Vite dev server (frontend HMR)
DEFAULT_BACKEND_PORT = 5180 # FastAPI in dev mode (Vite proxies /api here)
DEFAULT_PROD_PORT = 5080 # Production server (static files served by FastAPI)
# Template directory
TEMPLATE_DIR = Path(__file__).parent / "template"
# pyproject.toml additions for patched projects
PYPROJECT_ADDITIONS = {
"project": {
"dependencies": [
"fastapi-vue>=0.1.0",
],
},
"tool": {
"hatch": {
"build": {
"artifacts": ["{{MODULE_NAME}}/frontend-build"],
"targets": {
"sdist": {
"hooks": {
"custom": {"path": "scripts/fastapi-vue/build-frontend.py"}
},
}
},
"only-packages": True,
}
}
},
}
# =============================================================================
# Utility functions
# =============================================================================
def load_template(path: str) -> str:
"""Load a template file from the template directory."""
return (TEMPLATE_DIR / path).read_text()
def find_module_name(project_dir: Path) -> str | None:
"""Auto-detect the Python module name from pyproject.toml."""
pyproject = project_dir / "pyproject.toml"
if not pyproject.exists():
return None
with open(pyproject, "rb") as f:
data = tomllib.load(f)
if "project" in data and "name" in data["project"]:
name = data["project"]["name"]
return name.replace("-", "_")
return None
def find_fastapi_app(module_dir: Path) -> tuple[Path, str] | None:
"""Find the FastAPI app in a module directory.
Returns (file_path, app_variable_name) or None if not found.
"""
# Common app file names to check first
candidates = ["app.py", "main.py", "server.py", "api.py", "__init__.py"]
# Check common names first
for name in candidates:
path = module_dir / name
if path.exists():
result = _find_app_in_file(path)
if result:
return path, result
# Then check all .py files
for path in module_dir.glob("*.py"):
if path.name not in candidates:
result = _find_app_in_file(path)
if result:
return path, result
return None
def _find_app_in_file(path: Path) -> str | None:
"""Find FastAPI app variable name in a file."""
try:
content = path.read_text()
except Exception:
return None
# Look for FastAPI() instantiation patterns
# Matches: app = FastAPI(...) or application = FastAPI(...)
pattern = r"^(\w+)\s*=\s*FastAPI\s*\("
for match in re.finditer(pattern, content, re.MULTILINE):
return match.group(1)
return None
def render_template(template: str, **kwargs) -> str:
"""Simple template rendering with {{KEY}} placeholders."""
result = template
for key, value in kwargs.items():
result = result.replace(f"{{{{{key}}}}}", str(value))
return result
def patch_app_file(
path: Path, module_name: str, app_var: str, dry_run: bool = False
) -> bool:
"""Patch an existing app.py with frontend integration.
Inserts import and Frontend instantiation after imports, route at bottom,
and tries to patch lifespan with frontend.load().
Returns True if patched, False if already patched or failed.
"""
if not path.exists():
print(f"❌ Cannot patch {path} - file not found")
return False
content = path.read_text()
marker = "from fastapi_vue import Frontend"
if marker in content:
print(f"⚠️ Skipping {path} (already patched)")
return False
if dry_run:
print(f"[DRY RUN] Would patch {path}")
return True
# Find where to insert the import (after other imports)
lines = content.split("\n")
import_line = "from fastapi_vue import Frontend"
# Frontend instantiation block - use module_name for the frontend-build path
frontend_block = """
# Frontend static file server - configure options here
frontend = Frontend(
Path(__file__).parent / "frontend-build",
spa=True,
favicon="/assets/favicon",
cached=["/assets/"],
)
"""
route_line = f'frontend.route({app_var}, "/")'
# Find last import line and check if pathlib is imported
last_import_idx = 0
has_pathlib = False
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("import ") or stripped.startswith("from "):
last_import_idx = i
if "pathlib" in stripped or "from pathlib" in stripped:
has_pathlib = True
elif stripped and not stripped.startswith("#") and last_import_idx > 0:
# Stop at first non-import, non-comment, non-empty line after imports
break
# Insert imports after last import, then frontend instantiation
if not has_pathlib:
lines.insert(last_import_idx + 1, "from pathlib import Path")
last_import_idx += 1
lines.insert(last_import_idx + 1, import_line)
lines.insert(last_import_idx + 2, frontend_block)
# Append route at end
lines.append("")
lines.append(route_line)
content = "\n".join(lines)
# Try to patch lifespan function
lifespan_patched = False
# Look for async def lifespan pattern and insert after the opening (and docstring if present)
lifespan_pattern = r"(async\s+def\s+lifespan\s*\([^)]*\)\s*(?:->.*?)?:\s*\n)"
match = re.search(lifespan_pattern, content)
if match:
insert_pos = match.end()
rest = content[insert_pos:]
# Detect indentation from the next line
indent_match = re.match(r"([ \t]*)", rest)
indent = (
indent_match.group(1) if indent_match and indent_match.group(1) else " "
)
# Check if there's a docstring and skip past it
docstring_pattern = r'^([ \t]*)("""[\s\S]*?"""|\'\'\'\'[\s\S]*?\'\'\')\s*\n'
docstring_match = re.match(docstring_pattern, rest)
if docstring_match:
insert_pos += docstring_match.end()
load_code = f"{indent}await frontend.load()\n"
content = content[:insert_pos] + load_code + content[insert_pos:]
lifespan_patched = True
path.write_text(content)
print(f"✅ Patched {path}")
if not lifespan_patched:
# Check if they're using deprecated on_event
if "@" + app_var + ".on_event" in content or f"@{app_var}.on_event" in content:
print()
print("⚠️ Your app uses the deprecated @app.on_event decorator.")
print(" Please migrate to the lifespan pattern and add:")
print(" await frontend.load()")
print()
else:
print()
print("⚠️ Could not find lifespan function to patch.")
print(" Add this to your app's lifespan function:")
print(" await frontend.load()")
print()
return True
def patch_vite_config(
path: Path,
module_name: str,
backend_port: int,
vite_port: int,
dry_run: bool = False,
) -> bool:
"""Patch an existing vite.config.js/ts by adding fastapi-vue plugin.
This approach is cleaner than inline patching - we just add an import
and include the plugin in the plugins array.
"""
if not path.exists():
print(f"❌ Cannot patch {path} - file not found")
return False
content = path.read_text()
marker = "vite-plugin-fastapi"
if marker in content:
print(f"⚠️ Skipping {path} (already patched)")
return False
if dry_run:
print(f"[DRY RUN] Would patch {path}")
return True
# Add import for the plugin at the top (after other imports)
import_line = f"import fastapiVue from './{marker}.js'"
lines = content.split("\n")
new_lines = []
import_inserted = False
for i, line in enumerate(lines):
new_lines.append(line)
# Insert after the last import line before non-import content
if not import_inserted:
stripped = line.strip()
if stripped.startswith("import ") or stripped.startswith("from "):
# Check if next line is not an import
if i + 1 < len(lines):
next_stripped = lines[i + 1].strip()
if not next_stripped.startswith(
"import "
) and not next_stripped.startswith("from "):
new_lines.append(import_line)
import_inserted = True
if not import_inserted:
# No imports found, add at top
new_lines.insert(0, import_line)
content = "\n".join(new_lines)
# Add fastapiVue to plugins array
# Look for plugins: [ and add fastapiVue() as first entry
plugins_pattern = r"(plugins\s*:\s*\[)"
match = re.search(plugins_pattern, content)
if match:
insert_pos = match.end()
content = content[:insert_pos] + "\n fastapiVue()," + content[insert_pos:]
path.write_text(content)
print(f"✅ Patched {path}")
return True
def write_file(
path: Path, content: str, overwrite: bool = True, dry_run: bool = False
) -> bool:
"""Write content to a file, handling existing files and dry-run."""
exists = path.exists()
if exists and not overwrite:
print(f"⚠️ Skipping {path} (exists)")
return False
if dry_run:
action = "overwrite" if exists else "create"
print(f"[DRY RUN] Would {action} {path}")
return True
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
action = "Updated" if exists else "Created"
print(f"{action} {path}")
return True
def merge_pyproject(data: dict, additions: dict, module_name: str) -> dict:
"""Merge additions into pyproject.toml data."""
result = data.copy()
# Add dependencies
if "project" not in result:
result["project"] = {}
if "dependencies" not in result["project"]:
result["project"]["dependencies"] = []
for dep in additions["project"]["dependencies"]:
if not any(
dep.split("[")[0].split(">")[0] in d
for d in result["project"]["dependencies"]
):
result["project"]["dependencies"].append(dep)
# Add hatch build config
if "tool" not in result:
result["tool"] = {}
if "hatch" not in result["tool"]:
result["tool"]["hatch"] = {}
if "build" not in result["tool"]["hatch"]:
result["tool"]["hatch"]["build"] = {}
hatch_build = additions["tool"]["hatch"]["build"]
result["tool"]["hatch"]["build"]["artifacts"] = [
a.replace("{{MODULE_NAME}}", module_name) for a in hatch_build["artifacts"]
]
result["tool"]["hatch"]["build"]["only-packages"] = hatch_build["only-packages"]
if "targets" not in result["tool"]["hatch"]["build"]:
result["tool"]["hatch"]["build"]["targets"] = {}
result["tool"]["hatch"]["build"]["targets"]["sdist"] = hatch_build["targets"][
"sdist"
]
return result
# =============================================================================
# Command implementations
# =============================================================================
def find_js_runtime() -> tuple[str, str] | None:
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Returns None if no runtime is found.
"""
import shutil
options = ["deno", "npm", "bun"]
# Check for JS_RUNTIME environment variable
if js_runtime_env := os.environ.get("JS_RUNTIME"):
js_runtime = js_runtime_env
js_path = Path(js_runtime)
runtime_name = js_path.name
# Map node to npm
if runtime_name == "node":
runtime_name = "npm"
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
for option in options:
if option == runtime_name or runtime_name.startswith(option):
tool = shutil.which(js_runtime)
if tool is None:
print(f"⚠️ JS_RUNTIME={js_runtime_env} not found")
return None
return tool, option
print(f"⚠️ JS_RUNTIME={js_runtime_env} not recognized")
return None
# Auto-detect
for option in options:
if tool := shutil.which(option):
return tool, option
return None
def ensure_python_project(project_dir: Path, dry_run: bool = False) -> bool:
"""Ensure pyproject.toml exists, run uv init if needed."""
pyproject = project_dir / "pyproject.toml"
if pyproject.exists():
return True
if dry_run:
print(f"[DRY RUN] Would run: uv init {project_dir}")
return True
print("📦 No pyproject.toml found, initializing Python project...")
print(">>> uv init")
result = subprocess.run(["uv", "init", str(project_dir)], check=False)
if result.returncode != 0:
print("❌ uv init failed")
return False
# Remove hello.py if created
hello_py = project_dir / "hello.py"
if hello_py.exists():
hello_py.unlink()
# Remove .python-version if created
python_version = project_dir / ".python-version"
if python_version.exists():
python_version.unlink()
return True
def ensure_frontend(project_dir: Path, dry_run: bool = False) -> bool:
"""Ensure frontend directory exists, run create-vue if needed."""
frontend_dir = project_dir / "frontend"
if frontend_dir.exists():
return True
# Find JS runtime
runtime = find_js_runtime()
if runtime is None:
print("❌ No JavaScript runtime found (need deno, npm, or bun)")
return False
js_tool, js_name = runtime
# Build the create command based on runtime
create_vue_commands = {
"deno": [js_tool, "run", "-A", "npm:create-vue@latest", "frontend"],
"npm": [js_tool, "create", "vue@latest", "frontend"],
"bun": [js_tool, "create", "vue@latest", "frontend"],
}
create_cmd = create_vue_commands[js_name]
if dry_run:
print(f"[DRY RUN] Would run: {' '.join(create_cmd)}")
return True
print("🎨 No frontend/ found, creating Vue project...")
print(f">>> {' '.join(create_cmd)}")
print("(Follow the prompts to configure your Vue app)")
print()
result = subprocess.run(
create_cmd,
cwd=project_dir,
check=False,
)
if result.returncode != 0:
print("❌ create-vue failed")
return False
return True
def cmd_setup(args: argparse.Namespace) -> int:
"""Set up or update FastAPI+Vue integration in a project.
This unified command handles:
- Creating new projects (uv init + create-vue if needed)
- Patching existing projects with integration files
- Updating already-patched projects
"""
project_path = Path(args.project_dir)
# Handle both "." and "/path/to/project"
if project_path.is_absolute():
project_dir = project_path
else:
project_dir = Path.cwd() / project_path
project_dir = project_dir.resolve()
dry_run = args.dry_run
# Check if port options were explicitly specified (for updating existing files)
ports_specified = (
args.vite_port != DEFAULT_VITE_PORT
or args.backend_port != DEFAULT_BACKEND_PORT
or args.prod_port != DEFAULT_PROD_PORT
)
# Create project directory if it doesn't exist
if not project_dir.exists():
if dry_run:
print(f"[DRY RUN] Would create directory: {project_dir}")
else:
project_dir.mkdir(parents=True)
print(f"✅ Created {project_dir}")
print(f"🔧 Setting up project: {project_dir}")
if dry_run:
print("\n🏃 DRY RUN MODE - no changes will be made\n")
# Step 1: Ensure Python project exists
if not ensure_python_project(project_dir, dry_run):
return 1
# Step 2: Ensure frontend exists
if not ensure_frontend(project_dir, dry_run):
return 1
# Detect module name
module_name = args.module_name or find_module_name(project_dir)
if not module_name:
# Derive from directory name
module_name = project_dir.name.replace("-", "_")
print(f"📦 Using module name from directory: {module_name}")
# Ports
vite_port = args.vite_port
backend_port = args.backend_port
prod_port = args.prod_port
# Title for templates
project_title = module_name.replace("_", " ").title()
print(f"📦 Module: {module_name}")
print(f"🔌 Ports: Vite={vite_port}, Backend(dev)={backend_port}, Prod={prod_port}")
# Template variables
tpl_vars = {
"MODULE_NAME": module_name,
"PROJECT_TITLE": project_title,
"VITE_PORT": vite_port,
"BACKEND_PORT": backend_port,
"PROD_PORT": prod_port,
}
module_dir = project_dir / module_name
scripts_dir = project_dir / "scripts"
fastapi_vue_scripts = scripts_dir / "fastapi-vue"
# Find existing FastAPI app
app_info = find_fastapi_app(module_dir) if module_dir.exists() else None
if app_info:
app_file, app_var = app_info
print(f"📍 Found FastAPI app: {app_var} in {app_file.name}")
tpl_vars["APP_VAR"] = app_var
tpl_vars["APP_MODULE"] = app_file.stem
else:
print("📍 No existing FastAPI app found, will create new one")
app_file = None
app_var = "app"
tpl_vars["APP_VAR"] = app_var
tpl_vars["APP_MODULE"] = "app"
# Create directories
if not dry_run:
fastapi_vue_scripts.mkdir(parents=True, exist_ok=True)
if not module_dir.exists():
module_dir.mkdir(parents=True)
# === Install scripts (always update our own scripts) ===
script_files = [
(fastapi_vue_scripts / "util.py", "scripts/fastapi-vue/util.py"),
(
fastapi_vue_scripts / "build-frontend.py",
"scripts/fastapi-vue/build-frontend.py",
),
(scripts_dir / "devserver.py", "scripts/devserver.py"),
]
for dest_path, template_path in script_files:
template = load_template(template_path)
content = render_template(template, **tpl_vars)
write_file(dest_path, content, overwrite=True, dry_run=dry_run)
# === Handle app module ===
if app_file:
# Existing app: patch with import, route, and try to patch lifespan
patch_app_file(app_file, module_name, app_var, dry_run=dry_run)
else:
# No app: create full app.py
# Create __init__.py if missing
init_file = module_dir / "__init__.py"
if not init_file.exists():
template = load_template("backend/__init__.py")
content = render_template(template, **tpl_vars)
write_file(init_file, content, overwrite=False, dry_run=dry_run)
# Create app.py
app_file_path = module_dir / "app.py"
template = load_template("backend/app.py")
content = render_template(template, **tpl_vars)
write_file(app_file_path, content, overwrite=False, dry_run=dry_run)
# === Handle __main__.py ===
main_file = module_dir / "__main__.py"
if not main_file.exists() or ports_specified:
template = load_template("backend/__main__.py")
content = render_template(template, **tpl_vars)
write_file(main_file, content, overwrite=True, dry_run=dry_run)
else:
print(f"⚠️ Skipping {main_file} (exists, use --prod-port to update)")
# === Update vite.config.js/ts ===
frontend_dir = project_dir / "frontend"
if frontend_dir.exists():
# Install the vite plugin file (always update)
plugin_file = frontend_dir / "vite-plugin-fastapi.js"
template = load_template("frontend/vite-plugin-fastapi.js")
content = render_template(template, **tpl_vars)
write_file(plugin_file, content, overwrite=True, dry_run=dry_run)
# Find existing vite config (prefer .ts, fall back to .js)
vite_config_ts = frontend_dir / "vite.config.ts"
vite_config_js = frontend_dir / "vite.config.js"
if vite_config_ts.exists():
patch_vite_config(
vite_config_ts, module_name, backend_port, vite_port, dry_run
)
elif vite_config_js.exists():
patch_vite_config(
vite_config_js, module_name, backend_port, vite_port, dry_run
)
else:
print("⚠️ No vite.config.ts or vite.config.js found in frontend/")
print(" Run create-vue first to generate a Vite config to patch.")
# === Update pyproject.toml ===
pyproject_path = project_dir / "pyproject.toml"
if pyproject_path.exists():
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name)
# Add script entry pointing to the app we found/created
if "scripts" not in updated["project"]:
updated["project"]["scripts"] = {}
script_name = module_name.replace("_", "-")
updated["project"]["scripts"][script_name] = f"{module_name}.__main__:main"
if dry_run:
print(f"[DRY RUN] Would update {pyproject_path}")
else:
with open(pyproject_path, "wb") as f:
tomli_w.dump(updated, f)
print(f"✅ Updated {pyproject_path}")
# === Update .gitignore ===
gitignore_path = project_dir / ".gitignore"
gitignore_entry = f"{module_name}/frontend-build/"
if gitignore_path.exists():
gitignore_content = gitignore_path.read_text()
if gitignore_entry not in gitignore_content:
if dry_run:
print(f"[DRY RUN] Would add {gitignore_entry} to .gitignore")
else:
with open(gitignore_path, "a") as f:
if not gitignore_content.endswith("\n"):
f.write("\n")
f.write(f"{gitignore_entry}\n")
print(f"✅ Added {gitignore_entry} to .gitignore")
elif dry_run:
print(f"[DRY RUN] Would create .gitignore with {gitignore_entry}")
else:
gitignore_path.write_text(f"{gitignore_entry}\n")
print("✅ Created .gitignore")
print()
print("=" * 60)
print("✅ Setup complete!")
print("=" * 60)
print(f"""
Next steps:
1. Install dependencies:
cd {project_dir}
uv sync
2. Start development server:
uv run scripts/devserver.py
3. Build for production:
uv build
4. Run production server:
uv run {module_name.replace("_", "-")}
""")
return 0
# =============================================================================
# Main entry point
# =============================================================================
def is_uninitialized_folder(path: Path) -> bool:
"""Check if a folder appears to be completely uninitialized."""
return (
not (path / "pyproject.toml").exists() and not (path / "package.json").exists()
)
def is_already_patched(path: Path) -> bool:
"""Check if a folder has already been patched by fastapi-vue-setup."""
# Check for our scripts directory
if (path / "scripts" / "fastapi-vue").exists():
return True
# Check for vite plugin in frontend
if (path / "frontend" / "vite-plugin-fastapi.js").exists():
return True
return False
def main() -> int:
parser = argparse.ArgumentParser(
description="Set up FastAPI+Vue projects with integrated build/dev systems",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
fastapi-vue-setup my-new-project Create a new project from scratch
fastapi-vue-setup . Set up integration in current directory
fastapi-vue-setup . --dry-run Preview what would be done
fastapi-vue-setup . --vite-port 3000 Update port configuration
""",
)
parser.add_argument(
"project_dir",
nargs="?",
default=None,
help="Project directory (default: current directory)",
)
parser.add_argument("--module-name", help="Python module name (auto-detected)")
parser.add_argument(
"--vite-port",
type=int,
default=DEFAULT_VITE_PORT,
help=f"Vite dev server port (default: {DEFAULT_VITE_PORT})",
)
parser.add_argument(
"--backend-port",
type=int,
default=DEFAULT_BACKEND_PORT,
help=f"Backend API port in dev mode (default: {DEFAULT_BACKEND_PORT})",
)
parser.add_argument(
"--prod-port",
type=int,
default=DEFAULT_PROD_PORT,
help=f"Production server port (default: {DEFAULT_PROD_PORT})",
)
parser.add_argument(
"--dry-run", action="store_true", help="Show what would be done"
)
args = parser.parse_args()
# Handle default project directory with safety check
if args.project_dir is None:
cwd = Path.cwd()
if not is_uninitialized_folder(cwd) and not is_already_patched(cwd):
print(
"⚠️ Current directory contains an existing project that hasn't been patched yet."
)
print()
print(
" If you want to set up FastAPI+Vue integration in the current directory, run:"
)
print(" fastapi-vue-setup .")
print()
print(" Or specify a new project directory:")
print(" fastapi-vue-setup my-new-project")
print()
print(" Python project will be at root, while Vue lives in frontend/.")
print()
return 1
args.project_dir = "."
return cmd_setup(args)
if __name__ == "__main__":
sys.exit(main())
+29
View File
@@ -0,0 +1,29 @@
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "fastapi-vue-setup"
dynamic = ["version"]
description = "Tool to create or patch FastAPI+Vue projects with integrated build/dev systems"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"tomli-w>=1.0.0",
]
[project.urls]
Homepage = "https://git.zi.fi/LeoVasanko/fastapi-vue-setup"
Repository = "https://github.com/LeoVasanko/fastapi-vue-setup"
[project.scripts]
fastapi-vue-setup = "fastapi_vue_setup:main"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.targets.wheel]
include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"]
[dependency-groups]
dev = ["ruff"]
+3
View File
@@ -0,0 +1,3 @@
"""{{PROJECT_TITLE}} package."""
__version__ = "0.1.0"
+32
View File
@@ -0,0 +1,32 @@
"""Entry point for the application."""
import sys
def main():
"""Run the FastAPI application using uvicorn."""
import uvicorn
if len(sys.argv) > 1:
endpoint = sys.argv[1]
if ":" in endpoint:
host, port = endpoint.rsplit(":", 1)
host = host or "localhost"
port = int(port)
else:
host = "localhost"
port = int(endpoint)
else:
host = "localhost"
port = {{PROD_PORT}}
uvicorn.run(
"{{MODULE_NAME}}.app:app",
host=host,
port=port,
log_level="info",
)
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
"""Main FastAPI application."""
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi_vue import Frontend
# Frontend static file server - configure options here
frontend = Frontend(
Path(__file__).parent / "frontend-build",
spa=True,
favicon="/assets/favicon",
cached=["/assets/"],
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage app startup and shutdown resources."""
await frontend.load()
yield
app = FastAPI(title="{{PROJECT_TITLE}}", lifespan=lifespan)
@app.get("/api/health")
async def health_check():
"""Health check endpoint for the dev server."""
return {"status": "ok"}
# Add your API routes here
# @app.get("/api/example")
# async def example():
# return {"message": "Hello World"}
frontend.route(app, "/")
+37
View File
@@ -0,0 +1,37 @@
/**
* FastAPI-Vue Vite Plugin
*
* Configures Vite for FastAPI backend integration:
* - Proxies /api/* requests to the FastAPI backend
* - Builds to the Python module's frontend-build directory
*
* Environment variables (with defaults):
* VITE_PORT=5173 - Vite dev server port
* VITE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
*/
const backendUrl =
process.env.VITE_BACKEND_URL || "http://localhost:{{BACKEND_PORT}}";
const vitePort = parseInt(process.env.VITE_PORT || "{{VITE_PORT}}");
export default {
name: "fastapi-vite",
config: () => ({
server: {
host: "localhost",
port: vitePort,
strictPort: true,
proxy: {
"/api": {
target: backendUrl,
changeOrigin: false,
ws: true,
},
},
},
build: {
outDir: "../{{MODULE_NAME}}/frontend-build",
emptyOutDir: true,
},
}),
};
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env -S uv run
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
Usage:
uv run scripts/devserver.py [host:port]
The optional host:port argument sets where the Vite frontend listens.
Supported forms: host[:port], :port (all interfaces), or just port.
Backend always listens on localhost:{{BACKEND_PORT}}.
Environment:
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is.
"""
import asyncio
import contextlib
import ipaddress
import os
import sys
from pathlib import Path
from sys import stderr
from urllib.parse import urlparse
import httpx
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
DEFAULT_HOST = "localhost"
DEFAULT_VITE_PORT = {{VITE_PORT}}
BACKEND_PORT = {{BACKEND_PORT}}
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
BUN_BUG = """\
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
┃ Consider using deno or npm instead for development.
"""
def parse_endpoint(value: str | None) -> tuple[str | None, int, bool]:
"""Parse an endpoint for Vite (no unix socket support).
Returns (host, port, all_ifaces).
"""
if not value:
return DEFAULT_HOST, DEFAULT_VITE_PORT, False
# Port only (numeric) -> localhost:port
if value.isdigit():
return DEFAULT_HOST, int(value), False
# Leading colon :port -> bind all interfaces
if value.startswith(":") and value != ":":
port_part = value[1:]
if not port_part.isdigit():
raise SystemExit(f"Invalid port in '{value}'")
return None, int(port_part), True
# Unbracketed IPv6 (cannot safely contain a port)
if value.count(":") > 1 and not value.startswith("["):
try:
ipaddress.IPv6Address(value)
except ValueError as e:
raise SystemExit(f"Invalid IPv6 address '{value}': {e}") from e
return value, DEFAULT_VITE_PORT, False
# Use urllib.parse for everything else
parsed = urlparse(f"//{value}")
host = parsed.hostname or DEFAULT_HOST
port = parsed.port or DEFAULT_VITE_PORT
return host, port, False
def resolve_frontend_tools(
vite_host: str | None, vite_port: int, all_ifaces: bool
) -> tuple[list[str], list[str], str]:
"""Resolve frontend install and dev commands.
Returns (install_cmd, dev_cmd, tool_name).
Raises SystemExit if tools are not available.
"""
if not (FRONTEND_PATH / "package.json").exists():
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
raise SystemExit(1)
result = find_js_runtime()
if result is None:
if not os.environ.get("JS_RUNTIME"):
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
raise SystemExit(1)
tool, name = result
install_args = {
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
"npm": ("install", "--silent"),
"bun": ("install", "--silent"),
}
dev_args = {
"deno": ("run", "dev", "--"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}
install_cmd = [tool, *install_args[name]]
dev_cmd = [tool, *dev_args[name], "--port", str(vite_port)]
if all_ifaces:
dev_cmd.append("--host")
elif vite_host:
dev_cmd.extend(["--host", vite_host])
if name == "bun":
stderr.write(BUN_BUG)
return install_cmd, dev_cmd, name
async def wait_for_backend():
"""Wait for the backend to be ready by polling the health endpoint."""
max_attempts = 50
async with httpx.AsyncClient() as client:
for attempt in range(max_attempts):
try:
await client.get(f"http://localhost:{BACKEND_PORT}", timeout=1.0)
stderr.write("✓ Backend ready!\n")
return True
except httpx.RequestError:
if attempt == max_attempts - 1:
stderr.write("┃ ⚠️ Backend didn't start in time\n")
return False
await asyncio.sleep(0.1)
return False
async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> None:
"""Gracefully terminate a subprocess."""
if proc.returncode is not None:
return
try:
proc.terminate()
except ProcessLookupError:
return
try:
await asyncio.wait_for(proc.wait(), timeout=2)
except TimeoutError:
try:
proc.kill()
except ProcessLookupError:
return
await proc.wait()
async def run_devserver(
vite_host: str | None, vite_port: int, all_ifaces: bool
) -> None:
"""Run the development server with install, backend, and frontend."""
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(
vite_host, vite_port, all_ifaces
)
# Tell the backend where the Vite dev server is
os.environ["FASTAPI_VUE_FRONTEND_URL"] = (
f"http://{vite_host or 'localhost'}:{vite_port}"
)
# Tell Vite where the backend is (for proxying /api requests)
os.environ["VITE_BACKEND_URL"] = f"http://localhost:{BACKEND_PORT}"
cwd = str(Path(__file__).parent.parent)
frontend_cwd = str(FRONTEND_PATH)
backend_proc: asyncio.subprocess.Process | None = None
install_proc: asyncio.subprocess.Process | None = None
frontend_proc: asyncio.subprocess.Process | None = None
try:
# Start install (concurrent with backend)
stderr.write(f">>> {tool_name} {' '.join(install_cmd[1:])}\n")
install_proc = await asyncio.create_subprocess_exec(
*install_cmd, cwd=frontend_cwd
)
await asyncio.sleep(0.1)
# Start backend (concurrent with install)
backend_cmd = [
"uvicorn",
"{{MODULE_NAME}}.app:app",
"--host",
"localhost",
"--port",
str(BACKEND_PORT),
"--reload",
]
stderr.write(f">>> {' '.join(backend_cmd)}\n")
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
# Wait for install to complete and backend to be ready
install_task = asyncio.create_task(install_proc.wait(), name="install")
backend_ready_task = asyncio.create_task(
wait_for_backend(), name="backend_ready"
)
done, pending = await asyncio.wait(
{install_task, backend_ready_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in done:
if task.get_name() == "install":
if task.result() != 0:
stderr.write("┃ ⚠️ Install failed\n")
raise SystemExit(1)
elif task.get_name() == "backend_ready" and not task.result():
raise SystemExit(1)
if pending:
done2, _ = await asyncio.wait(pending)
for task in done2:
if task.get_name() == "install":
if task.result() != 0:
stderr.write("┃ ⚠️ Install failed\n")
raise SystemExit(1)
elif task.get_name() == "backend_ready" and not task.result():
raise SystemExit(1)
install_proc = None
# Start Vite dev server
stderr.write(f">>> {tool_name} {' '.join(dev_cmd[1:])}\n")
frontend_proc = await asyncio.create_subprocess_exec(*dev_cmd, cwd=frontend_cwd)
# Wait for either process to exit
done, pending = await asyncio.wait(
{
asyncio.create_task(backend_proc.wait(), name="backend"),
asyncio.create_task(frontend_proc.wait(), name="frontend"),
},
return_when=asyncio.FIRST_COMPLETED,
)
for t in done:
t.result()
for t in pending:
t.cancel()
except asyncio.CancelledError:
stderr.write("\n✓ Shutting down...\n")
finally:
if frontend_proc is not None:
await _terminate_process(frontend_proc, "frontend")
if install_proc is not None:
await _terminate_process(install_proc, "install")
if backend_proc is not None:
await _terminate_process(backend_proc, "backend")
def main():
hostport = sys.argv[1] if len(sys.argv) > 1 else None
vite_host, vite_port, all_ifaces = parse_endpoint(hostport)
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(run_devserver(vite_host, vite_port, all_ifaces))
if __name__ == "__main__":
main()
@@ -0,0 +1,34 @@
"""Hatch build hook for building Vue frontend during package build."""
import subprocess
from pathlib import Path
from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
def run(cmd, **kwargs):
"""Run a command and display it."""
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
stderr.write(f"### {' '.join(display_cmd)}\n")
subprocess.run(cmd, check=True, **kwargs)
class CustomBuildHook(BuildHookInterface):
"""Build hook that compiles Vue frontend before packaging."""
def initialize(self, version, build_data):
super().initialize(version, build_data)
stderr.write(">>> Building the frontend\n")
install_cmd, build_cmd = find_build_tool()
try:
run(install_cmd, cwd="frontend")
stderr.write("\n")
run(build_cmd, cwd="frontend")
except Exception as e:
stderr.write(f"Error occurred while building frontend: {e}\n")
raise
+86
View File
@@ -0,0 +1,86 @@
"""Shared utilities for build and dev scripts."""
import os
import shutil
from pathlib import Path
from sys import stderr
def find_js_runtime() -> tuple[str, str] | None:
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Returns None if no runtime is found.
"""
options = ["deno", "npm", "bun"]
# Check for JS_RUNTIME environment variable
if js_runtime_env := os.environ.get("JS_RUNTIME"):
js_runtime = js_runtime_env
js_path = Path(js_runtime)
runtime_name = js_path.name
# Map node to npm
if runtime_name == "node":
runtime_name = "npm"
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
for option in options:
if option == runtime_name or runtime_name.startswith(option):
tool = shutil.which(js_runtime)
if tool is None:
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not found\n")
return None
return tool, option
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not recognized\n")
return None
# Auto-detect
for option in options:
if tool := shutil.which(option):
return tool, option
return None
def find_build_tool():
"""Find JavaScript runtime and construct install/build commands.
Returns (install_cmd, build_cmd) tuples of command lists.
Raises RuntimeError if no runtime is found.
"""
install = {
"deno": ("install", "--allow-scripts=npm:vue-demi"),
"npm": ("install",),
"bun": ("--bun", "install"),
}
build = {
"deno": ("task", "build"),
"npm": ("run", "build"),
"bun": ("--bun", "run", "build"),
}
result = find_js_runtime()
if result is None:
raise RuntimeError(
"Deno, npm or Bun is required for building but none was found"
)
tool, name = result
return [tool, *install[name]], [tool, *build[name]]
def find_dev_tool():
"""Find JavaScript runtime and construct dev command.
Returns (dev_cmd, tool_name) or (None, None) if not found.
"""
dev_args = {
"deno": ("run", "dev", "--"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}
result = find_js_runtime()
if result is None:
return None, None
tool, name = result
return [tool, *dev_args[name]], name