@@ -8,11 +8,12 @@ Usage:
Options:
--module-name NAME Python module name (auto-detected from pyproject.toml)
--ports DEFAULT,VITE,DEV Port configuration (default: 3100,3100,3200)
--dry-run Show what would be done without making changes
--dry Show what would be done without making changes
"""
import argparse
import ast
import importlib . metadata
import os
import re
import shutil
@@ -23,6 +24,8 @@ from textwrap import indent
import tomlkit
version = importlib . metadata . version ( " fastapi-vue-setup " )
# Track Python files written/patched for ruff formatting
_python_files_to_format : list [ Path ] = [ ]
@@ -30,19 +33,27 @@ _python_files_to_format: list[Path] = []
TEMPLATE_DIR = Path ( __file__ ) . parent / " template "
def ruff_sort_imports ( files : list [ Path ] , dry_run : bool = False ) - > None :
def print_boxed ( text : str ) - > None :
""" Print text in a Unicode rounded box. """
width = len ( text ) + 2
print ( f " ╭ { ' ─ ' * width } ╮ " )
print ( f " │ { text } │ " )
print ( f " ╰ { ' ─ ' * width } ╯ " )
def ruff_sort_imports ( files : list [ Path ] , dry : bool = False ) - > None :
""" Run ruff to sort imports in the given Python files. """
if not files :
return
py_files = [ str ( f ) for f in files if f . suffix == " .py " and f . exists ( ) ]
if not py_files :
return
if dry_run :
if dry :
print ( f " 🔧 Would run ruff import sorting on { len ( py_files ) } files " )
return
print ( " 🔧 Ruff isort on modified files " )
subprocess . run (
[ " ruff " , " check " , " --select " , " I " , " --fix " , * py_files ] ,
[ sys . executable , " -m " , " ruff " , " check " , " --select " , " I " , " --fix " , * py_files ] ,
stdout = subprocess . DEVNULL ,
)
@@ -95,7 +106,16 @@ PYPROJECT_ADDITIONS = {
# Frontend instantiation block for patching existing apps
FRONTEND_BLOCK = """
# Vue Frontend static files
frontend = Frontend(Path(__file__).with_name( " frontend-build " ), cached=[ " /assets/ " ] )
frontend = Frontend(Path(__file__).with_name( " frontend-build " ))
"""
# Lifespan block for patching apps that don't have one
LIFESPAN_BLOCK = """
@asynccontextmanager
async def lifespan(app: FastAPI):
\" \" \" Manage app startup and shutdown resources. \" \" \"
await frontend.load()
yield
"""
# TypeScript health check script for Vue components
@@ -106,7 +126,7 @@ const backendStatus = ref<'checking' | 'connected' | 'error'>('checking')
onMounted(async () => {
try {
const res = await fetch( ' /api/health ' )
const res = await fetch( ' /api/health?from=frontend ' )
backendStatus.value = res.ok ? ' connected ' : ' error '
} catch {
backendStatus.value = ' error '
@@ -122,7 +142,7 @@ const backendStatus = ref('checking')
onMounted(async () => {
try {
const res = await fetch( ' /api/health ' )
const res = await fetch( ' /api/health?from=frontend ' )
backendStatus.value = res.ok ? ' connected ' : ' error '
} catch {
backendStatus.value = ' error '
@@ -141,17 +161,16 @@ STATUS_SPAN_TEMPLATE = """\
"""
# Setup complete message template
SETUP_COMPLETE_MESSAGE = """
Next steps:
1. Build for production:
CD_CMDuv build
2. Start development server:
SETUP_COMPLETE_MESSAGE = """ \
## Development server: (live reloads, debug)
CD_CMDuv run scripts/devserver.py
3. Run p roduction server :
CD_CMDuv run SCRIPT_NAME
## P roduction build :
CD_CMDuv build && uv run SCRIPT_NAME
## Release Python package, run anywhere:
CD_CMDuv build && uv publish
uvx SCRIPT_NAME # No Node required
"""
@@ -473,7 +492,7 @@ def render_template(template: str, **kwargs) -> str:
def patch_app_file (
path : Path , module_name : str , app_var : str , dry_run : bool = False
path : Path , module_name : str , app_var : str , dry : bool = False
) - > bool :
""" Patch an existing app.py with frontend integration.
@@ -493,8 +512,9 @@ def patch_app_file(
has_frontend = " from fastapi_vue import Frontend " in content
has_devmode = f " from { module_name } .__main__ import DEVMODE " in content
has_debug_arg = re . search ( r " FastAPI \ s* \ ([^)]*debug \ s*= " , content ) is not None
has_lifespan = " await frontend.load() " in content
if has_frontend and has_devmode and has_debug_arg :
if has_frontend and has_devmode and has_debug_arg and has_lifespan :
print ( f " ✔️ { path } (already patched) " )
return False
@@ -532,8 +552,11 @@ def patch_app_file(
elif stripped and not stripped . startswith ( " # " ) and last_import_idx > 0 :
break
lines . insert ( last_import_idx + 1 , FRONTEND_BLOCK )
content = " \n " . join ( lines )
# Append route at end
# Append route at end (only if not already present)
if route_line not in content :
lines = content . split ( " \n " )
lines . append ( " " )
lines . append (
" # Serve the Vue frontend (needs to be last if SPA catch-all is used) "
@@ -547,9 +570,9 @@ def patch_app_file(
for match in re . finditer ( fastapi_pattern , content , re . DOTALL ) :
args = match . group ( 2 )
if " debug " not in args :
# Add debug=DEVMODE as fir st argument
# Add debug=DEVMODE as la st argument
if args . strip ( ) :
new_args = f " debug=DEVMODE, { args } "
new_args = f " { args } , debug=DEVMODE"
else :
new_args = " debug=DEVMODE "
content = (
@@ -576,12 +599,58 @@ def patch_app_file(
content = content [ : insert_pos ] + load_code + content [ insert_pos : ]
lifespan_patched = True
# No lifespan at all: create one and wire it into FastAPI()
if not lifespan_patched and f " @ { app_var } .on_event " not in content :
# Add contextlib import
if " from contextlib import asynccontextmanager " not in content :
insert_line = find_import_insertion_line ( content )
lines = content . splitlines ( keepends = True )
insert_idx = insert_line - 1
import_text = " from contextlib import asynccontextmanager \n "
if insert_idx > = len ( lines ) :
content = content . rstrip ( " \n " ) + " \n " + import_text
else :
content = (
" " . join ( lines [ : insert_idx ] )
+ import_text
+ " " . join ( lines [ insert_idx : ] )
)
# Insert lifespan block before the FastAPI() call
fastapi_line_pattern = r " ^( \ w+ \ s*= \ s*FastAPI \ s* \ () "
fastapi_match = re . search ( fastapi_line_pattern , content , re . MULTILINE )
if fastapi_match :
content = (
content [ : fastapi_match . start ( ) ]
+ LIFESPAN_BLOCK . lstrip ( " \n " )
+ " \n "
+ content [ fastapi_match . start ( ) : ]
)
# Add lifespan=lifespan to FastAPI() call
fastapi_pattern = r " ( \ w+ \ s*= \ s*FastAPI \ s* \ ()([^)]*) \ ) "
fastapi_match = re . search ( fastapi_pattern , content , re . DOTALL )
if fastapi_match and " lifespan " not in fastapi_match . group ( 2 ) :
args = fastapi_match . group ( 2 )
if args . strip ( ) :
new_args = f " { args } , lifespan=lifespan "
else :
new_args = " lifespan=lifespan "
content = (
content [ : fastapi_match . start ( ) ]
+ fastapi_match . group ( 1 )
+ new_args
+ " ) "
+ content [ fastapi_match . end ( ) : ]
)
lifespan_patched = True
# Check if content actually changed
if content == original_content :
print ( f " ⚠️ Skipping { path } (no changes needed) " )
return False
if dry_run :
if dry :
print ( f " ✅ Would patch { path } " )
return True
@@ -610,7 +679,7 @@ def patch_app_file(
def patch_vite_config (
path : Path ,
module_name : str ,
dry_run : bool = False ,
dry : bool = False ,
) - > bool :
""" Patch an existing vite.config.js/ts by adding fastapi-vue plugin.
@@ -672,7 +741,7 @@ def patch_vite_config(
print ( f " ℹ ️ Skipping { path } (no changes needed) " )
return False
if dry_run :
if dry :
print ( f " ✅ Would patch { path } " )
return True
@@ -681,7 +750,7 @@ def patch_vite_config(
return True
def patch_frontend_health_check ( frontend_dir : Path , dry_run : bool = False ) - > bool :
def patch_frontend_health_check ( frontend_dir : Path , dry : bool = False ) - > bool :
""" Patch Vue app to include FastAPI backend health check.
Tries HelloWorld.vue first (full demo), then falls back to App.vue (minimal).
@@ -772,7 +841,7 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
print ( f " ℹ ️ Skipping { target_file } (no changes needed) " )
return False
if dry_run :
if dry :
print ( f " ✅ Would patch { target_file } " )
return True
@@ -781,6 +850,40 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
return True
# SHA-256 of old vite-plugin-fastapi.js (before auto-upgrade marker was added)
# with module name replaced by MODULE_NAME in the outDir path
_OLD_VITE_PLUGIN_SHA256 = (
" 93713e879c15a25c750a70ce1de684adeaf11b0c723c38da56e5e7ba207f6632 "
)
def _upgrade_old_vite_plugin ( path : Path , module_name : str , dry : bool = False ) - > None :
""" Remove old vite-plugin-fastapi.js that lacks auto-upgrade marker.
Old versions didn ' t have the upgrade marker, so write_file skips them as
' customized by user ' . We recognize the old version by normalizing the module
name in outDir and comparing the SHA-256 hash.
"""
if not path . exists ( ) :
return
content = path . read_text ( " UTF-8 " )
if UPGRADE_MARKER in content :
return # Already new format, write_file handles it
import hashlib
normalized = content . replace (
f " ../ { module_name } /frontend-build " , " ../MODULE_NAME/frontend-build "
)
digest = hashlib . sha256 ( normalized . encode ( ) ) . hexdigest ( )
if digest != _OLD_VITE_PLUGIN_SHA256 :
return # Modified by user, don't touch
if dry :
print ( f " 🔄 Would upgrade old { path } " )
return
path . unlink ( )
print ( f " 🔄 Removing old { path } (will be replaced) " )
# Track .new.py files written during setup (for merge notification)
_new_files_written : list [ tuple [ Path , Path ] ] = [ ]
@@ -789,7 +892,7 @@ def write_file(
path : Path ,
content : str ,
overwrite : bool = True ,
dry_run : bool = False ,
dry : bool = False ,
executable : bool = False ,
fallback_path : Path | None = None ,
force : bool = False ,
@@ -818,12 +921,12 @@ def write_file(
if fallback_path is not None :
# Write to fallback path instead
return _write_fallback_file (
path , fallback_path , content , dry_run , executable
path , fallback_path , content , dry , executable
)
print ( f " ℹ ️ Skipping { path } (customized by user) " )
return False
if dry_run :
if dry :
action = " overwrite " if exists else " create "
print ( f " ✅ Would { action } { path } " )
return True
@@ -843,7 +946,7 @@ def _write_fallback_file(
original_path : Path ,
fallback_path : Path ,
content : str ,
dry_run : bool ,
dry : bool ,
executable : bool ,
) - > bool :
""" Write content to a fallback .new.py file when original can ' t be overwritten. """
@@ -853,7 +956,7 @@ def _write_fallback_file(
print ( f " ✔️ { fallback_path } (already up to date) " )
return False
if dry_run :
if dry :
print ( f " ✅ Would create { fallback_path } (original customized by user) " )
_new_files_written . append ( ( fallback_path , original_path ) )
return True
@@ -990,13 +1093,13 @@ def find_js_runtime() -> tuple[str, str] | None:
return None
def ensure_python_project ( project_dir : Path , dry_run : bool = False ) - > bool :
def ensure_python_project ( project_dir : Path , dry : 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 :
if dry :
print ( f " 📦 Would run: uv init { project_dir } " )
return True
@@ -1016,7 +1119,7 @@ def ensure_python_project(project_dir: Path, dry_run: bool = False) -> bool:
return True
def ensure_frontend ( project_dir : Path , dry_run : bool = False ) - > bool :
def ensure_frontend ( project_dir : Path , dry : bool = False ) - > bool :
""" Ensure frontend directory exists with a Vue project, run create-vue if needed. """
frontend_dir = project_dir / " frontend "
package_json = frontend_dir / " package.json "
@@ -1040,7 +1143,7 @@ def ensure_frontend(project_dir: Path, dry_run: bool = False) -> bool:
}
create_cmd = create_vue_commands [ js_name ]
if dry_run :
if dry :
print ( f " 🎨 Would run: { ' ' . join ( create_cmd ) } " )
return True
@@ -1080,11 +1183,15 @@ def cmd_setup(args: argparse.Namespace) -> int:
project_dir = Path . cwd ( ) / project_path
project_dir = project_dir . resolve ( )
dry_run = args . dry_run
dry = args . dry
print_boxed ( f " fastapi-vue-setup { version } " )
if dry :
print ( " 🏃 DRY RUN MODE - no changes will be made \n " )
# Create project directory if it doesn't exist
if not project_dir . exists ( ) :
if dry_run :
if dry :
print ( f " ✅ Would create directory: { project_dir } " )
else :
project_dir . mkdir ( parents = True )
@@ -1092,15 +1199,12 @@ def cmd_setup(args: argparse.Namespace) -> int:
print ( f " 🔧 Setting up project: { project_dir } " )
if dry_run :
print ( " \n 🏃 DRY RUN MODE - no changes will be made \n " )
# Step 1: Ensure frontend exists (do this first so cancellation doesn't leave partial setup)
if not ensure_frontend ( project_dir , dry_run ) :
if not ensure_frontend ( project_dir , dry ) :
return 1
# Step 2: Ensure Python project exists
if not ensure_python_project ( project_dir , dry_run ) :
if not ensure_python_project ( project_dir , dry ) :
return 1
# Detect module name
@@ -1166,7 +1270,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
tpl_vars [ " APP_MODULE " ] = " app "
# Create directories
if not dry_run :
if not dry :
if not module_dir . exists ( ) :
module_dir . mkdir ( parents = True )
fastapi_vue_scripts . mkdir ( parents = True , exist_ok = True )
@@ -1178,7 +1282,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
# Remove obsolete util.py if present
obsolete_util = fastapi_vue_scripts / " util.py "
if obsolete_util . exists ( ) :
if dry_run :
if dry :
print ( f " 🗑️ Would remove obsolete { obsolete_util } " )
else :
obsolete_util . unlink ( )
@@ -1195,7 +1299,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
dest_path ,
content ,
overwrite = True ,
dry_run = dry_run ,
dry = dry ,
force = True , # Internal files, always overwrite
)
@@ -1208,7 +1312,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
devserver_path ,
content ,
overwrite = True ,
dry_run = dry_run ,
dry = dry ,
executable = True ,
fallback_path = devserver_fallback ,
)
@@ -1216,7 +1320,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
# === 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 )
patch_app_file ( app_file , module_name , app_var , dry = dry )
else :
# No app: create full app.py
# Create __init__.py if missing
@@ -1224,13 +1328,13 @@ def cmd_setup(args: argparse.Namespace) -> int:
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 )
write_file ( init_file , content , overwrite = False , dry = dry )
# 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 )
write_file ( app_file_path , content , overwrite = False , dry = dry )
# === Handle __main__.py ===
main_file = module_dir / " __main__.py "
@@ -1247,7 +1351,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
main_file ,
main_content ,
overwrite = True ,
dry_run = dry_run ,
dry = dry ,
fallback_path = main_fallback ,
)
elif not existing_cli :
@@ -1256,7 +1360,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
main_file ,
main_content ,
overwrite = False ,
dry_run = dry_run ,
dry = dry ,
)
else :
# No file but has existing entrypoint - don't create (user has custom CLI setup)
@@ -1267,24 +1371,26 @@ def cmd_setup(args: argparse.Namespace) -> int:
if frontend_dir . exists ( ) :
# Install the vite plugin file (always update)
plugin_file = frontend_dir / " vite-plugin-fastapi.js "
# Upgrade old plugin versions that lack the auto-upgrade marker
_upgrade_old_vite_plugin ( plugin_file , module_name , dry )
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 )
write_file ( plugin_file , content , overwrite = True , dry = dry )
# 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 , dry_run )
patch_vite_config ( vite_config_ts , module_name , dry )
elif vite_config_js . exists ( ) :
patch_vite_config ( vite_config_js , module_name , dry_run )
patch_vite_config ( vite_config_js , module_name , dry )
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. " )
# Patch Vue app with backend health check
patch_frontend_health_check ( frontend_dir , dry_run )
patch_frontend_health_check ( frontend_dir , dry )
# === Update pyproject.toml ===
pyproject_path = project_dir / " pyproject.toml "
@@ -1305,7 +1411,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
if new_content == old_content :
print ( f " ✔️ { pyproject_path } (already up to date) " )
elif dry_run :
elif dry :
print ( f " ✅ Would update { pyproject_path } " )
else :
pyproject_path . write_text ( new_content , " UTF-8 " , newline = " \n " )
@@ -1318,7 +1424,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
gitignore_content = gitignore_path . read_bytes ( )
if b " frontend-build " in gitignore_content :
print ( " ✔️ .gitignore (frontend-build already ignored) " )
elif dry_run :
elif dry :
print ( f " ✅ Would add { gitignore_entry } to .gitignore " )
else :
nl = b " \r \n " if b " \r \n " in gitignore_content else b " \n "
@@ -1327,15 +1433,15 @@ def cmd_setup(args: argparse.Namespace) -> int:
gitignore_content + suffix + gitignore_entry . encode ( ) + nl
)
print ( f " ✅ Added { gitignore_entry } to .gitignore " )
elif dry_run :
elif dry :
print ( f " ✅ Would create .gitignore with { gitignore_entry } " )
else :
gitignore_path . write_text ( f " { gitignore_entry } \n " , " UTF-8 " , newline = " \n " )
print ( " ✅ Created .gitignore " )
# === Add dependencies using uv ===
ruff_sort_imports ( _python_files_to_format , dry_run = dry_run )
if dry_run :
ruff_sort_imports ( _python_files_to_format , dry = dry )
if dry :
print ( " 📦 Would add: fastapi[standard], fastapi-vue, httpx (dev only) " )
else :
print ( " 📦 Dependencies " )
@@ -1343,12 +1449,10 @@ def cmd_setup(args: argparse.Namespace) -> int:
uv_add_packages ( [ " httpx " ] , cwd = project_dir , group = " dev " )
print ( )
print ( " = " * 60 )
print ( " ✅ Setup complete! " )
print ( " = " * 60 )
print_boxed ( " Setup complete! " )
# Show cd command only if project is not in current directory
cd_cmd = " " if project_dir == Path . cwd ( ) else f " cd { project_dir } \n "
cd_cmd = " " if project_dir == Path . cwd ( ) else f " cd { project_dir } ; "
script_name = module_name . replace ( " _ " , " - " )
message = SETUP_COMPLETE_MESSAGE . replace ( " CD_CMD " , cd_cmd ) . replace (
@@ -1372,11 +1476,6 @@ def cmd_setup(args: argparse.Namespace) -> int:
return 0
# =============================================================================
# Main entry point
# =============================================================================
def is_uninitialized_folder ( path : Path ) - > bool :
""" Check if a folder appears to be completely uninitialized. """
return (
@@ -1399,14 +1498,14 @@ def is_already_patched(path: Path) -> bool:
def main ( ) - > int :
parser = argparse . ArgumentParser (
description = " S et up FastAPI+ Vue projects with integrated build/dev systems " ,
description = f " fastapi-vue-s etup { version } - FastAPI + Vue project setup tool " ,
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 . --ports 8000,5173,8080 Custom ports (default,vite, dev)
fastapi-vue-setup . --dry Preview what would be done
fastapi-vue-setup . --ports= 8000,5173,8080 Change default ports (backend, vite dev, backend dev)
""" ,
)
parser . add_argument (
@@ -1415,14 +1514,15 @@ Examples:
default = None ,
help = " Project directory (use . for current directory) " ,
)
parser . add_argument ( " --version " , action = " version " , version = version )
parser . add_argument ( " --module-name " , help = " Python module name (auto-detected) " )
parser . add_argument (
" --ports " ,
metavar = " DEFAULT ,VITE,DEV" ,
metavar = " BACKEND ,VITE,DEV" ,
help = " Port configuration as comma-separated values (default: 3100,3100,3200) " ,
)
parser . add_argument (
" --dry-run " , action = " store_true " , help = " Show what would be done "
" --dry " , " --dry-run " , action = " store_true " , help = " Show what would be done "
)
args = parser . parse_args ( )