Compare commits

...
10 Commits
4 changed files with 89 additions and 91 deletions
+77 -74
View File
@@ -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,
)
@@ -115,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'
@@ -131,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'
@@ -150,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 production server:
CD_CMDuv run SCRIPT_NAME
## Production build:
CD_CMDuv build && uv run SCRIPT_NAME
## Release Python package, run anywhere:
CD_CMDuv build && uv publish
uvx SCRIPT_NAME # No Node required
"""
@@ -482,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.
@@ -640,7 +650,7 @@ def patch_app_file(
print(f"⚠️ Skipping {path} (no changes needed)")
return False
if dry_run:
if dry:
print(f"✅ Would patch {path}")
return True
@@ -669,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.
@@ -731,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
@@ -740,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).
@@ -831,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
@@ -847,9 +857,7 @@ _OLD_VITE_PLUGIN_SHA256 = (
)
def _upgrade_old_vite_plugin(
path: Path, module_name: str, dry_run: bool = False
) -> None:
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
@@ -869,7 +877,7 @@ def _upgrade_old_vite_plugin(
digest = hashlib.sha256(normalized.encode()).hexdigest()
if digest != _OLD_VITE_PLUGIN_SHA256:
return # Modified by user, don't touch
if dry_run:
if dry:
print(f"🔄 Would upgrade old {path}")
return
path.unlink()
@@ -884,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,
@@ -913,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
@@ -938,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."""
@@ -948,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
@@ -1085,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
@@ -1111,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"
@@ -1135,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
@@ -1175,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)
@@ -1187,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
@@ -1261,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)
@@ -1273,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()
@@ -1290,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
)
@@ -1303,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,
)
@@ -1311,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
@@ -1319,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"
@@ -1342,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:
@@ -1351,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)
@@ -1363,25 +1372,25 @@ def cmd_setup(args: argparse.Namespace) -> int:
# 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_run)
_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"
@@ -1402,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")
@@ -1415,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"
@@ -1424,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")
@@ -1440,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(
@@ -1469,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 (
@@ -1496,14 +1498,14 @@ def is_already_patched(path: Path) -> bool:
def main() -> int:
parser = argparse.ArgumentParser(
description="Set up FastAPI+Vue projects with integrated build/dev systems",
description=f"fastapi-vue-setup {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(
@@ -1512,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()
+1 -1
View File
@@ -5,7 +5,7 @@ import os
from fastapi_vue import server
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
DEVMODE = bool(os.getenv("ENVPREFIX_FRONTEND_URL"))
DEVMODE = bool(os.getenv("ENVPREFIX_DEV") == "1")
def main():
+10 -10
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env -S uv run
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
"""Run Vite development server for frontend and FastAPI backend with auto-reload."""
"""Run Vite development server for Vue app and FastAPI backend with auto-reload."""
import argparse
import asyncio
@@ -25,7 +25,7 @@ DEFAULT_DEV_PORT = TEMPLATE_DEV_PORT
async def run_devserver(
frontend: str, backend: str, extra_args: list[str] | None = None
listen: str, backend: str, extra_args: list[str] | None = None
) -> None:
reporoot = Path(__file__).parent.parent
front = reporoot / "frontend"
@@ -33,12 +33,13 @@ async def run_devserver(
logger.warning("Frontend source not found at %s", front)
raise SystemExit(1)
viteurl, npm_install, vite = setup_vite(frontend, DEFAULT_VITE_PORT)
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
backurl, MODULE_NAME = setup_cli("PROJECT_CLI", backend, DEFAULT_DEV_PORT)
# Tell the everyone where the frontend and backend are (vite proxy, etc)
os.environ["ENVPREFIX_FRONTEND_URL"] = viteurl
# Tell the everyone by environment (vite proxy and backend devmode use these)
os.environ["ENVPREFIX_VITE_URL"] = viteurl
os.environ["ENVPREFIX_BACKEND_URL"] = backurl
os.environ["ENVPREFIX_DEV"] = "1"
async with ProcessGroup() as pg:
npm_i = await pg.spawn(*npm_install, cwd=front)
@@ -55,19 +56,18 @@ def main():
epilog=HELP_EPILOG,
)
parser.add_argument(
"frontend",
nargs="?",
"-l", "--listen",
metavar="host:port",
help=f"Vite frontend endpoint (default: localhost:{DEFAULT_VITE_PORT})",
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
)
parser.add_argument(
"--backend",
metavar="host:port",
help=f"FastAPI backend endpoint (default: localhost:{DEFAULT_DEV_PORT})",
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
)
args, extra_args = parser.parse_known_args()
with suppress(KeyboardInterrupt):
asyncio.run(run_devserver(args.frontend, args.backend, extra_args))
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
HELP_EPILOG = """
+1 -6
View File
@@ -207,10 +207,5 @@ def setup_cli(
host = endpoints[0]["host"]
port = endpoints[0]["port"]
cmd = [
sys.executable,
"-m",
cli,
f"--listen={host}:{port}",
]
cmd = [cli, f"--listen={host}:{port}"]
return f"http://{host}:{port}", cmd