Files
fastapi-vue-setup/fastapi_vue_setup.py

1854 lines
67 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)
--ports DEFAULT,VITE,DEV Port configuration (default: 3100,3100,3200)
--dry Show what would be done without making changes
-- ARGS Extra arguments forwarded to create-vue (e.g. -- --default)
"""
import argparse
import ast
import contextlib
import hashlib
import importlib.metadata
import os
import platform
import re
import shutil
import subprocess
import sys
from pathlib import Path
from textwrap import indent
from typing import Literal
import tomlkit
version = importlib.metadata.version("fastapi-vue-setup")
# Template directory
TEMPLATE_DIR = Path(__file__).parent / "template"
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_format_content(
content: str, target_path: Path, *, mode: Literal["isort", "full"] = "full"
) -> str:
"""Format Python content using ruff with project settings.
Writes to a temp file next to target, runs ruff check (import sorting)
and optionally ruff format on it, reads back the result, and cleans up.
Returns the formatted content, or original if ruff fails.
mode='isort' only sorts imports; mode='full' also formats.
"""
temp_file = target_path.with_suffix(".new.py")
try:
temp_file.write_text(content, "UTF-8", newline="\n")
if mode == "isort":
# Sort imports only (ignore exit code)
result = subprocess.run( # noqa: S603
[ # noqa: S607
"ruff",
"check",
"--select",
"I",
"--fix",
"--output-format=concise",
str(temp_file),
],
cwd=target_path.parent,
capture_output=True,
check=False,
)
if result.returncode != 0:
print(result.stdout.decode())
return temp_file.read_text("UTF-8")
# Full mode: fix all auto-fixable lint violations (ignore exit code)
result = subprocess.run( # noqa: S603
[ # noqa: S607
"ruff",
"check",
"--ignore=EXE001,INP001,N999,CPY001",
"--fix",
"--output-format=concise",
str(temp_file),
],
cwd=target_path.parent,
capture_output=True,
check=False,
)
if result.returncode != 0:
print(result.stdout.decode())
# Then format (ignore exit code)
result = subprocess.run( # noqa: S603
["ruff", "format", str(temp_file)], # noqa: S607
cwd=target_path.parent,
capture_output=True,
check=False,
)
if result.returncode != 0:
print(result.stdout.decode())
return temp_file.read_text("UTF-8")
except OSError:
pass
finally:
with contextlib.suppress(Exception):
temp_file.unlink(missing_ok=True)
return content
def uv_add_packages(packages: list[str], *, cwd: Path, group: str | None = None) -> None:
"""Add packages using uv.
Uses --frozen so only pyproject.toml is edited, without locking or
syncing - those happen in a single uv sync step after all changes.
"""
cmd = ["uv", "add", "-q", "--frozen"]
if group:
cmd.extend(["--group", group])
cmd.extend(packages)
result = subprocess.run(cmd, cwd=cwd, check=False) # noqa: S603
if result.returncode != 0:
label = f" ({group})" if group else ""
print(f"⚠️ Failed to add{label} dependencies")
# Default ports: (default, vite, dev)
# If vite == dev, dev is incremented by 100
DEFAULT_PORTS = (3100, 3100, 3200)
# Default health check path for devserver backend readiness check
DEFAULT_HEALTH = "/api/health?from=devserver.py"
# Marker comment indicating file can be auto-upgraded
# Users should remove this line to prevent automatic updates
UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup"
# pyproject.toml additions for patched projects
PYPROJECT_ADDITIONS = {
"tool": {
"hatch": {
"build": {
"packages": ["MODULE_NAME"],
"artifacts": ["MODULE_NAME/frontend-build"],
"targets": {
"sdist": {
"hooks": {"custom": {"path": "scripts/fastapi-vue/buildhook.py"}},
}
},
"only-packages": True,
}
}
},
}
# Old build hook path that should be migrated to the new name
OLD_BUILD_HOOK_PATH = "scripts/fastapi-vue/build-frontend.py"
NEW_BUILD_HOOK_PATH = "scripts/fastapi-vue/buildhook.py"
# Frontend instantiation block for patching existing apps
FRONTEND_BLOCK = """
# Vue Frontend static files
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
TS_HEALTH_CHECK_SCRIPT = """\
import { ref, onMounted } from 'vue'
const backendStatus = ref<'checking' | 'connected' | 'error'>('checking')
onMounted(async () => {
try {
const res = await fetch('/api/health?from=frontend')
backendStatus.value = res.ok ? 'connected' : 'error'
} catch {
backendStatus.value = 'error'
}
})
"""
# JavaScript health check script for Vue components
JS_HEALTH_CHECK_SCRIPT = """\
import { ref, onMounted } from 'vue'
const backendStatus = ref('checking')
onMounted(async () => {
try {
const res = await fetch('/api/health?from=frontend')
backendStatus.value = res.ok ? 'connected' : 'error'
} catch {
backendStatus.value = 'error'
}
})
"""
# Status indicator template for Vue components
STATUS_SPAN_TEMPLATE = """\
<span style="white-space: nowrap">
— FastAPI:
<span v-if="backendStatus === 'checking'">⏳</span>
<span v-else-if="backendStatus === 'connected'">✅</span>
<span v-else>❌ not reachable</span>
</span>
"""
# Setup complete message template
SETUP_COMPLETE_MESSAGE = """\
## Development server: (live reloads, debug)
CD_CMDuv run scripts/devserver.py
## 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
"""
# =============================================================================
# Utility functions
# =============================================================================
def parse_ports(ports_str: str | None) -> tuple[int, int, int]:
"""Parse comma-separated port string into (default, vite, dev) tuple.
If dev == vite, dev is incremented by 100 to avoid conflicts.
"""
if not ports_str:
return DEFAULT_PORTS
parts = ports_str.split(",")
if len(parts) == 1:
default = int(parts[0])
vite = default
dev = default + 100
elif len(parts) == 2:
default = int(parts[0])
vite = int(parts[1])
dev = vite + 100 if vite == default else default + 100
elif len(parts) == 3:
default = int(parts[0])
vite = int(parts[1])
dev = int(parts[2])
else:
msg = f"Invalid ports format: {ports_str}"
raise ValueError(msg)
# Auto-adjust dev if it conflicts with vite
if dev == vite:
dev = vite + 100
return default, vite, dev
def find_import_insertion_line(source: str) -> int:
"""Find line number (1-based) for inserting imports, after shebang/docstring."""
try:
tree = ast.parse(source)
except SyntaxError:
return 2 if source.startswith("#!") else 1
# Find first import, or end of docstring if no imports
for node in tree.body:
if isinstance(node, (ast.Import, ast.ImportFrom)):
return node.lineno
if not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)):
break # Non-import, non-docstring statement
# No imports found - insert after docstring or at line 1
if tree.body and isinstance(tree.body[0], ast.Expr):
return tree.body[0].end_lineno + 1
return 2 if source.startswith("#!") else 1
def extract_existing_ports(project_dir: Path, main: Path) -> tuple[int, int, int] | None:
"""Extract existing port configuration from project files.
Returns (default, vite, dev) or None if not found.
"""
default_port = None
vite_port = None
dev_port = None
# Try to extract DEFAULT_PORT from the CLI main module
if main.exists():
content = main.read_text("UTF-8")
match = re.search(r"DEFAULT_PORT\s*=\s*(\d+)", content)
if match:
default_port = int(match.group(1))
# Try to extract ports from devserver.py
devserver_file = project_dir / "scripts" / "devserver.py"
if devserver_file.exists():
content = devserver_file.read_text("UTF-8")
match = re.search(r"DEFAULT_VITE_PORT\s*=\s*(\d+)", content)
if match:
vite_port = int(match.group(1))
match = re.search(r"DEFAULT_DEV_PORT\s*=\s*(\d+)", content)
if match:
dev_port = int(match.group(1))
# Return only if we found at least one port
if default_port is not None or vite_port is not None or dev_port is not None:
return (
default_port or DEFAULT_PORTS[0],
vite_port or DEFAULT_PORTS[1],
dev_port or DEFAULT_PORTS[2],
)
return None
# Sentinel for "not found" in extract_existing_health
_HEALTH_NOT_FOUND = object()
def extract_existing_health(project_dir: Path) -> str | object:
"""Extract existing health path configuration from devserver.py.
Returns:
- The path string (may be empty to disable)
- _HEALTH_NOT_FOUND sentinel if not found or file doesn't exist
"""
devserver_file = project_dir / "scripts" / "devserver.py"
if not devserver_file.exists():
return _HEALTH_NOT_FOUND
content = devserver_file.read_text("UTF-8")
# Match HEALTH = "/path" or HEALTH = ""
match = re.search(r'^HEALTH\s*=\s*"([^"]*)"', content, re.MULTILINE)
if match:
return match.group(1)
return _HEALTH_NOT_FOUND
def load_template(path: str) -> str:
"""Load a template file from the template directory."""
return (TEMPLATE_DIR / path).read_text("UTF-8")
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
data = tomlkit.parse(pyproject.read_text("UTF-8"))
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, project_dir: Path | None = None) -> tuple[Path, str] | None:
"""Find the FastAPI app in a module directory.
Returns (file_path, app_variable_name) or None if not found.
Search order:
1. Common app files in module_dir (app.py, main.py, etc.)
2. All .py files in module_dir
3. Subpackage indicated by CLI entrypoint in pyproject.toml
4. Follow re-exports in __init__.py files
"""
# 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 in module_dir
for path in module_dir.glob("*.py"):
if path.name not in candidates:
result = _find_app_in_file(path)
if result:
return path, result
# Try to find app via CLI entrypoint in pyproject.toml
if project_dir:
result = _find_app_via_entrypoint(module_dir, project_dir)
if result:
return result
return None
def _find_app_via_entrypoint(module_dir: Path, project_dir: Path) -> tuple[Path, str] | None:
"""Find FastAPI app by following the CLI entrypoint in pyproject.toml.
If pyproject.toml has a script like `myapp = "myapp.subpkg.__main__:main"`,
look in myapp/subpkg/ for the app (checking __init__.py exports and common files).
"""
pyproject = project_dir / "pyproject.toml"
if not pyproject.exists():
return None
try:
data = tomlkit.parse(pyproject.read_text("UTF-8"))
except (OSError, ValueError):
return None
scripts = data.get("project", {}).get("scripts", {})
if not scripts:
return None
module_name = module_dir.name
# Find script entries that reference this module
for entry in scripts.values():
if not isinstance(entry, str):
continue
# Parse entry like "module.subpkg.__main__:main"
if ":" not in entry:
continue
module_path, _ = entry.rsplit(":", 1)
parts = module_path.split(".")
# Check if this entry starts with our module
if not parts or parts[0] != module_name:
continue
# If there's a subpackage (e.g., module.fastapi.__main__), check there
if len(parts) >= 2:
# Build path to subpackage (exclude __main__ or similar)
subpkg_parts = [p for p in parts[1:] if not p.startswith("_")]
if subpkg_parts:
subpkg_dir = module_dir / "/".join(subpkg_parts)
if subpkg_dir.is_dir():
result = _find_app_in_subpackage(subpkg_dir)
if result:
return result
return None
def _find_app_in_subpackage(subpkg_dir: Path) -> tuple[Path, str] | None:
"""Find FastAPI app in a subpackage, following __init__.py exports."""
# First check __init__.py for re-exports like `from .mainapp import app`
init_file = subpkg_dir / "__init__.py"
if init_file.exists():
result = _follow_init_reexport(init_file, subpkg_dir)
if result:
return result
# Check common app file names in subpackage
for name in ["app.py", "main.py", "mainapp.py", "server.py", "api.py"]:
path = subpkg_dir / name
if path.exists():
result = _find_app_in_file(path)
if result:
return path, result
return None
def _migrate_devmode_in_main(content: str) -> str | None:
"""Spot-patch the pre-1.6 DEVMODE mechanism to the FASTAPI_VUE env prefix.
Replaces `DEVMODE = os.getenv("PREFIX_DEV") == "1"` with
`os.environ["FASTAPI_VUE"] = "PREFIX"` and remaining DEVMODE references
with env.dev, ensuring env is imported from fastapi_vue.
Returns the patched content, or None if there was nothing to patch.
"""
match = re.search(
r"^DEVMODE\s*=\s*os\.getenv\(\s*[\"']([A-Za-z0-9_]+)_DEV[\"']\s*\)\s*==\s*[\"']1[\"']",
content,
re.MULTILINE,
)
if not match:
return None
content = (
content[: match.start()]
+ f'os.environ["FASTAPI_VUE"] = "{match.group(1)}"'
+ content[match.end() :]
)
# Replace every remaining standalone DEVMODE reference (as in app.py
# migration, string literals are an accepted risk)
content = re.sub(r"(?<![\w.])DEVMODE\b", "env.dev", content)
if not re.search(r"^from fastapi_vue import\b.*\benv\b", content, re.MULTILINE):
if re.search(r"^from fastapi_vue import ", content, re.MULTILINE):
content = re.sub(
r"^from fastapi_vue import ",
"from fastapi_vue import env, ",
content,
count=1,
flags=re.MULTILINE,
)
else:
insert_line = find_import_insertion_line(content)
lines = content.splitlines(keepends=True)
insert_idx = insert_line - 1
import_text = "from fastapi_vue import env\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:])
return content
def _patch_main_devmode(path: Path, *, dry: bool) -> str | None:
"""Apply _migrate_devmode_in_main to a main module in place, if needed.
Done in place even without the auto-upgrade marker: the marker guards
full-file overwrites, while leaving this change to a .new.py merge would
silently break dev mode for every customized pre-1.6 main.
Returns the migrated content if the module was (or would be) patched,
None if there was nothing to patch.
"""
content = path.read_text("UTF-8")
migrated = _migrate_devmode_in_main(content)
if migrated is None:
return None
migrated = ruff_format_content(migrated, path, mode="isort")
if dry:
print(f"✅ Would patch {path} (DEVMODE → FASTAPI_VUE)")
return migrated
path.write_text(migrated, "UTF-8", newline="\n")
print(f"✅ Patched {path} (DEVMODE → FASTAPI_VUE)")
return migrated
def _add_env_prefix_to_main(content: str) -> str:
"""Add FASTAPI_VUE environment prefix setup to an existing main module."""
lines = content.splitlines()
# Check if os is imported
has_os_import = any("import os" in line for line in lines)
# Find the first import line
insert_idx = 0
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith(("import ", "from ")):
insert_idx = i + 1
elif stripped and not stripped.startswith("#"):
break
# Insert imports and env setup after existing imports
new_lines = []
if not has_os_import:
new_lines.append("import os")
new_lines.extend(
[
"",
"# Added by fastapi-vue-setup",
'os.environ["FASTAPI_VUE"] = "ENVPREFIX"',
"",
]
)
lines[insert_idx:insert_idx] = new_lines
return "\n".join(lines)
def _find_existing_cli_module_path(project_dir: Path, module_name: str) -> str | None:
"""Check if pyproject.toml already has a CLI entrypoint for this module.
Returns the module path (e.g., 'module.subpkg.__main__') if found, None otherwise.
"""
pyproject = project_dir / "pyproject.toml"
if not pyproject.exists():
return None
try:
data = tomlkit.parse(pyproject.read_text("UTF-8"))
except (OSError, ValueError):
return None
scripts = data.get("project", {}).get("scripts", {})
if not scripts:
return None
# Look for any script that references our module
for entry in scripts.values():
if isinstance(entry, str) and entry.startswith(f"{module_name}.") and ":" in entry:
# Extract module path from "module.subpkg.__main__:main"
module_path, _ = entry.rsplit(":", 1)
return module_path
return None
def _follow_init_reexport(init_file: Path, subpkg_dir: Path) -> tuple[Path, str] | None:
"""Follow a re-export in __init__.py to find the actual app file.
Looks for patterns like:
- from .mainapp import app
- from module.subpkg.mainapp import app
"""
try:
content = init_file.read_text("UTF-8")
except OSError:
return None
# Look for: from .module import app (or similar variable names)
# Pattern matches: from .mainapp import app, application, etc.
pattern = r"from\s+\.(\w+)\s+import\s+(\w+)"
for match in re.finditer(pattern, content):
module_name, var_name = match.groups()
if var_name.lower() in ("app", "application", "api"):
target_file = subpkg_dir / f"{module_name}.py"
if target_file.exists():
# Verify the app is actually there
app_var = _find_app_in_file(target_file)
if app_var:
return target_file, app_var
# Also check for absolute imports: from pkg.subpkg.module import app
abs_pattern = r"from\s+[\w.]+\.(\w+)\s+import\s+(\w+)"
for match in re.finditer(abs_pattern, content):
module_name, var_name = match.groups()
if var_name.lower() in ("app", "application", "api"):
target_file = subpkg_dir / f"{module_name}.py"
if target_file.exists():
app_var = _find_app_in_file(target_file)
if app_var:
return target_file, app_var
return None
def _find_app_in_file(path: Path) -> str | None:
"""Find FastAPI app variable name in a file."""
try:
content = path.read_text("UTF-8")
except OSError:
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) -> str:
"""Render a template, replacing KEY with value."""
result = template
for key, value in kwargs.items():
result = result.replace(key, value)
return result
def needs_app_migration(project_dir: Path) -> bool:
"""Check if the project was set up with fastapi-vue older than 1.6.
Those versions patched app.py with a DEVMODE import from the main module;
1.6+ uses env.dev from fastapi_vue instead. Must be called before the
dependency step rewrites the fastapi-vue requirement in pyproject.toml.
"""
pyproject = project_dir / "pyproject.toml"
if not pyproject.exists():
return False
data = tomlkit.parse(pyproject.read_text("UTF-8"))
for dep in data.get("project", {}).get("dependencies", []):
match = re.match(r"\s*fastapi-vue(?:\[[^\]]*\])?\s*(.*)", str(dep))
if match:
version = re.search(r"(\d+)\.(\d+)", match.group(1))
return version is not None and (int(version[1]), int(version[2])) < (1, 6)
return False
def patch_app_file(
path: Path, main_module_path: str, app_var: str, *, migrate: bool = False, dry: bool = False
) -> bool:
"""Patch an existing app.py with frontend integration.
Inserts imports at top (ruff will sort them), route at bottom,
and tries to patch lifespan with frontend.load(). With migrate=True,
pre-1.6 patching (DEVMODE import from the main module) is first
rewritten to the current format (env.dev).
Returns True if patched, False if already patched or failed.
"""
if not path.exists():
print(f"❌ Cannot patch {path} - file not found")
return False
original_content = path.read_text("UTF-8")
content = original_content
# Migrate pre-1.6 patching to the current format: DEVMODE via
# fastapi_vue.env (the Frontend import stays as-is)
if migrate:
old_import = f"from {main_module_path} import DEVMODE"
if old_import in content:
# The import sort at the end merges this with any existing
# `from fastapi_vue import Frontend` line
content = content.replace(old_import, "from fastapi_vue import env")
# Replace every remaining standalone DEVMODE reference (not just
# the debug= parameter); it may also appear inside string literals,
# but that's an accepted risk over AST rewriting
content = re.sub(r"(?<![\w.])DEVMODE\b", "env.dev", content)
# Check what's already patched; plain "Frontend(" so user modifications
# of the integration (renames, different call shape) still count
has_frontend = "Frontend(" in content
has_debug_arg = re.search(r"FastAPI\s*\([^)]*debug\s*=", content) is not None
has_lifespan = "await frontend.load()" in content
already_patched = has_frontend and has_debug_arg and has_lifespan
if content == original_content and already_patched:
print(f"✔️ {path} (already patched)")
return False
route_line = f'frontend.route({app_var}, "/")'
# Add missing imports (using AST to find correct insertion point);
# the import sort at the end merges duplicate from-imports
imports = []
if not has_frontend:
imports.append("from pathlib import Path")
if not has_frontend or not re.search(
r"^from fastapi_vue import\b.*\benv\b", content, re.MULTILINE
):
imports.append("from fastapi_vue import Frontend, env")
if imports:
insert_line = find_import_insertion_line(content)
lines = content.splitlines(keepends=True)
# Convert to 0-based index
insert_idx = insert_line - 1
import_text = "\n".join(imports) + "\n"
if insert_idx >= len(lines):
# Append at end
content = content.rstrip("\n") + "\n" + import_text
else:
# Insert at the found position
content = "".join(lines[:insert_idx]) + import_text + "".join(lines[insert_idx:])
# Insert FRONTEND_BLOCK after last import (only if Frontend wasn't already there)
if not has_frontend:
lines = content.split("\n")
last_import_idx = 0
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith(("import ", "from ")):
last_import_idx = i
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 (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)")
lines.append(route_line)
content = "\n".join(lines)
# Try to patch FastAPI() call with debug=env.dev if no debug arg exists
if not has_debug_arg:
fastapi_pattern = r"(\w+\s*=\s*FastAPI\s*\()([^)]*)\)"
for match in re.finditer(fastapi_pattern, content, re.DOTALL):
args = match.group(2)
if "debug" not in args:
# Add debug=env.dev as last argument
new_args = (f"{args}, " if args.strip() else "") + "debug=env.dev"
content = (
content[: match.start()]
+ match.group(1)
+ new_args
+ ")"
+ content[match.end() :]
)
break # Only patch first FastAPI() call
# Try to patch lifespan function - insert await frontend.load() before yield
lifespan_patched = "await frontend.load()" in content
# Look for yield inside an async def lifespan function
# Find the yield statement and insert before it
if not lifespan_patched:
yield_pattern = r"^([ \t]+)(yield\b)"
yield_match = re.search(yield_pattern, content, re.MULTILINE)
if yield_match:
ws = yield_match.group(1)
insert_pos = yield_match.start()
load_code = f"{ws}await frontend.load()\n"
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)
new_args = f"{args}, lifespan=lifespan" if args.strip() else "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:
print(f"✅ Would patch {path}")
return True
# Sort imports only (avoid full formatting of user code)
content = ruff_format_content(content, path, mode="isort")
path.write_text(content, "UTF-8", newline="\n")
print(f"✅ Patched {path}")
if not lifespan_patched:
# Check if they're using deprecated on_event
if 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,
*,
dry: 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
original_content = path.read_text("UTF-8")
marker = "vite-plugin-fastapi"
if marker in original_content:
print(f"✔️ {path} (already patched)")
return False
# Add import for the plugin at the top (after other imports)
import_line = f"import fastapiVue from './{marker}.js'"
lines = original_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 ", "from ")) and 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:]
else:
print(f"⚠️ Skipping {path} (no plugins array found)")
return False
# Check if content actually changed
if content == original_content:
print(f"️ Skipping {path} (no changes needed)")
return False
if dry:
print(f"✅ Would patch {path}")
return True
path.write_text(content, "UTF-8", newline="\n")
print(f"✅ Patched {path}")
return True
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).
Works with both JS and TS versions created by create-vue.
"""
# Find the file to patch - prefer HelloWorld.vue, fall back to App.vue
target_file = None
# Try HelloWorld.vue first (full demo app)
hello_world = frontend_dir / "src" / "components" / "HelloWorld.vue"
if hello_world.exists():
target_file = hello_world
else:
# Try finding HelloWorld.vue elsewhere
for path in frontend_dir.glob("src/**/HelloWorld.vue"):
target_file = path
break
# Fall back to App.vue (minimal app)
if target_file is None:
app_vue = frontend_dir / "src" / "App.vue"
if app_vue.exists():
target_file = app_vue
if target_file is None:
print("️ No default App.vue found to patch, not adding /api/health check")
return False
original_content = target_file.read_text("UTF-8")
# Check if already patched
if "/api/health" in original_content:
print(f"✔️ {target_file} (already patched)")
return False
content = original_content
# Detect if TypeScript (has lang="ts" in script tag)
is_typescript = 'lang="ts"' in content
# Build the script content based on JS/TS
script_addition = TS_HEALTH_CHECK_SCRIPT if is_typescript else JS_HEALTH_CHECK_SCRIPT
# Insert script addition before </script>
script_end_match = re.search(r"</script>", content)
if not script_end_match:
print(f"⚠️ Skipping {target_file} (no </script> tag found)")
return False
insert_pos = script_end_match.start()
content = content[:insert_pos] + script_addition + content[insert_pos:]
# Insert status inline - find the best place based on file type
# For HelloWorld.vue: insert before </h3>
# For App.vue (minimal): only patch if it's the default "You did it!" template
if "HelloWorld" in str(target_file):
# Insert before closing </h3>
h3_close = content.find(" </h3>")
if h3_close == -1:
print(f"⚠️ Skipping {target_file} (no </h3> tag found for status insertion)")
return False
before, after = content[:h3_close], content[h3_close:]
content = f"{before}{indent(STATUS_SPAN_TEMPLATE, ' ')}{after}"
else:
# Minimal App.vue - only patch if it contains the default welcome message
if "<h1>You did it!</h1>" not in content:
print(f"️ Skipping {target_file} (not a Vue demo app)")
return False
# Insert before the </p> tag
template_end = content.find("</template>")
if template_end == -1:
print(f"⚠️ Skipping {target_file} (no </template> tag found)")
return False
# Find </p> before </template>
last_p = content.rfind("</p>", 0, template_end)
if last_p == -1:
print(f"⚠️ Skipping {target_file} (no </p> tag found for status insertion)")
return False
before, after = content[:last_p], content[last_p:]
content = f"{before}{STATUS_SPAN_TEMPLATE}{after}"
# Check if content actually changed
if content == original_content:
print(f"️ Skipping {target_file} (no changes needed)")
return False
if dry:
print(f"✅ Would patch {target_file}")
return True
target_file.write_text(content, "UTF-8", newline="\n")
print(f"✅ Patched {target_file}")
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
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]] = []
def _strip_upgrade_marker(content: str) -> str:
"""Remove the auto-upgrade marker line, for content comparison."""
return "\n".join(line for line in content.splitlines() if UPGRADE_MARKER not in line)
def write_file(
path: Path,
content: str,
*,
overwrite: bool = True,
dry: bool = False,
executable: bool = False,
fallback_path: Path | None = None,
force: bool = False,
) -> bool:
"""Write content to a file, handling existing files and dry-run.
If fallback_path is provided and the file exists without the upgrade marker,
the content will be written to fallback_path instead of being skipped.
If force=True, always overwrite without checking for upgrade marker.
Python files (.py) are automatically formatted using ruff with the project's
settings before writing.
"""
# Format Python content using project settings before any comparison/writing
if path.suffix == ".py":
content = ruff_format_content(content, path)
exists = path.exists()
if exists and not overwrite:
print(f"⚠️ Skipping {path} (exists)")
return False
# Check if content is the same (new content already formatted)
if exists:
existing_content = path.read_text("UTF-8")
if existing_content == content:
print(f"✔️ {path} (already up to date)")
return False
# If overwrite requested but file doesn't have upgrade marker (unless force)
if overwrite and not force and UPGRADE_MARKER not in existing_content:
if fallback_path is not None:
# Write to fallback path instead
return _write_fallback_file(
path, fallback_path, content, dry=dry, executable=executable
)
print(f"️ Skipping {path} (customized by user)")
return False
if dry:
action = "overwrite" if exists else "create"
print(f"✅ Would {action} {path}")
return True
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, "UTF-8", newline="\n")
if executable and sys.platform != "win32":
path.chmod(path.stat().st_mode | 0o111)
action = "Updated" if exists else "Created"
print(f"✅ {action} {path}")
return True
def _write_fallback_file(
original_path: Path,
fallback_path: Path,
content: str,
*,
dry: bool,
executable: bool,
) -> bool:
"""Write content to a fallback .new.py file when original can't be overwritten.
Note: content should already be formatted before calling this function.
"""
# Check if fallback already has same content (content already formatted)
if fallback_path.exists():
existing_content = fallback_path.read_text("UTF-8")
if existing_content == content:
print(f"✔️ {fallback_path} (already up to date)")
_new_files_written.append((fallback_path, original_path))
return False
if dry:
print(f"✅ Would create {fallback_path} (original customized by user)")
_new_files_written.append((fallback_path, original_path))
return True
fallback_path.parent.mkdir(parents=True, exist_ok=True)
fallback_path.write_text(content, "UTF-8", newline="\n")
if executable and sys.platform != "win32":
fallback_path.chmod(fallback_path.stat().st_mode | 0o111)
print(f"✅ Created {fallback_path} (original customized by user)")
_new_files_written.append((fallback_path, original_path))
return True
def merge_pyproject(
data: tomlkit.TOMLDocument, additions: dict, module_name: str
) -> tomlkit.TOMLDocument:
"""Merge additions into pyproject.toml data, preserving comments and existing values."""
# Ensure hatchling build system is configured
if "build-system" not in data:
data["build-system"] = tomlkit.table()
# Add hatchling to requires if not present, preserving existing requires
if "requires" not in data["build-system"]:
data["build-system"]["requires"] = ["hatchling"]
else:
requires = list(data["build-system"]["requires"])
if not any(r.startswith("hatchling") for r in requires):
requires.insert(0, "hatchling")
data["build-system"]["requires"] = requires
if "build-backend" not in data["build-system"]:
data["build-system"]["build-backend"] = "hatchling.build"
# Ensure project table exists
if "project" not in data:
data["project"] = tomlkit.table()
# Ensure Python version is at least 3.11 (required by fastapi-vue)
if "requires-python" in data["project"]:
req = data["project"]["requires-python"]
# Parse minimum version from strings like ">=3.10" or ">=3.9,<4"
match = re.search(r">=\s*(\d+)\.(\d+)", req)
if match:
major, minor = int(match.group(1)), int(match.group(2))
if major < 3 or (major == 3 and minor < 11):
data["project"]["requires-python"] = ">=3.11"
else:
data["project"]["requires-python"] = ">=3.11"
# Add hatch build config
if "tool" not in data:
data["tool"] = tomlkit.table()
if "hatch" not in data["tool"]:
data["tool"]["hatch"] = tomlkit.table()
if "build" not in data["tool"]["hatch"]:
data["tool"]["hatch"]["build"] = tomlkit.table()
hatch_build = data["tool"]["hatch"]["build"]
hatch_additions = additions["tool"]["hatch"]["build"]
# Set packages if not already set
if "packages" not in hatch_build:
hatch_build["packages"] = [
p.replace("MODULE_NAME", module_name) for p in hatch_additions["packages"]
]
# Set artifacts if not already set
if "artifacts" not in hatch_build:
hatch_build["artifacts"] = [
a.replace("MODULE_NAME", module_name) for a in hatch_additions["artifacts"]
]
# Set only-packages if not already set
if "only-packages" not in hatch_build:
hatch_build["only-packages"] = hatch_additions["only-packages"]
# Add sdist target with custom hook
if "targets" not in hatch_build:
hatch_build["targets"] = tomlkit.table()
if "sdist" not in hatch_build["targets"]:
hatch_build["targets"]["sdist"] = tomlkit.table()
if "hooks" not in hatch_build["targets"]["sdist"]:
hatch_build["targets"]["sdist"]["hooks"] = tomlkit.table()
if "custom" not in hatch_build["targets"]["sdist"]["hooks"]:
hatch_build["targets"]["sdist"]["hooks"]["custom"] = tomlkit.table()
if "path" not in hatch_build["targets"]["sdist"]["hooks"]["custom"]:
hatch_build["targets"]["sdist"]["hooks"]["custom"]["path"] = hatch_additions["targets"][
"sdist"
]["hooks"]["custom"]["path"]
elif hatch_build["targets"]["sdist"]["hooks"]["custom"]["path"] == OLD_BUILD_HOOK_PATH:
# Migrate old build hook path to new name
hatch_build["targets"]["sdist"]["hooks"]["custom"]["path"] = NEW_BUILD_HOOK_PATH
return data
# =============================================================================
# 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.
"""
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: bool = False) -> bool:
"""Ensure pyproject.toml exists, run uv init if needed."""
pyproject = project_dir / "pyproject.toml"
if pyproject.exists():
return True
if dry:
print(f"📦 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) # noqa: S603, S607
if result.returncode != 0:
print("❌ uv init failed")
return False
# Remove files created by uv init that we don't need
for filename in ["hello.py", "main.py", ".python-version"]:
filepath = project_dir / filename
if filepath.exists():
filepath.unlink()
return True
def ensure_frontend(
project_dir: Path, *, vue_args: list[str] | None = None, 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"
# Check for package.json, not just directory existence (empty dir shouldn't count)
if package_json.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 vue_args:
# npm needs a `--` separator so it doesn't eat the arguments;
# create-vue runs non-interactively when given feature flags (e.g. --default)
create_cmd = [*create_cmd, *(["--"] if js_name == "npm" else []), *vue_args]
if dry:
print(f"🎨 Would run: {' '.join(create_cmd)}")
return True
print("🎨 No frontend/ found, creating Vue project...")
print(f">>> {' '.join(create_cmd)}")
if not vue_args:
print("(Follow the prompts to configure your Vue app)")
print()
result = subprocess.run(create_cmd, cwd=project_dir, check=False) # noqa: S603
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
"""
# Clear any existing virtual environment to avoid uv using the wrong one (warning)
os.environ.pop("VIRTUAL_ENV", None)
project_path = Path(args.project_dir)
# Handle both "." and "/path/to/project"
project_dir = project_path if project_path.is_absolute() else Path.cwd() / project_path
project_dir = project_dir.resolve()
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:
print(f"✅ Would create directory: {project_dir}")
else:
project_dir.mkdir(parents=True)
print(f"✅ Created {project_dir}")
print(f"🔧 Setting up project: {project_dir}")
# Step 1: Ensure frontend exists (do this first so cancellation doesn't leave partial setup)
if not ensure_frontend(project_dir, vue_args=args.vue_args, dry=dry):
return 1
# Step 2: Ensure Python project exists
if not ensure_python_project(project_dir, dry=dry):
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"📦 Module: {module_name} (from directory name)")
else:
print(f"📦 Module: {module_name}")
module_dir = project_dir / module_name
scripts_dir = project_dir / "scripts"
fastapi_vue_scripts = scripts_dir / "fastapi-vue"
# Check if project already has a CLI entrypoint in pyproject.toml
existing_cli_module = _find_existing_cli_module_path(project_dir, module_name)
# Determine main module path (for migrating old DEVMODE imports)
main_module_path = existing_cli_module or f"{module_name}.__main__"
if existing_cli_module:
print(f"️ Using existing CLI: {existing_cli_module}")
# Resolve the CLI main module to a file path
main = project_dir / Path(*main_module_path.split(".")).with_suffix(".py")
# Determine port configuration
# Priority: --ports argument > existing project values > defaults
if args.ports:
default_port, vite_port, dev_port = parse_ports(args.ports)
ports_note = "(--ports)"
else:
existing_ports = extract_existing_ports(project_dir, main)
if existing_ports:
default_port, vite_port, dev_port = existing_ports
ports_note = "(kept for upgrade)"
else:
default_port, vite_port, dev_port = DEFAULT_PORTS
ports_note = "(--ports to override)"
print(f"📡 Ports: default={default_port}, vite={vite_port}, dev={dev_port} {ports_note}")
# Determine health path configuration
# Priority: --health argument > existing project value > default
if args.health is not None:
health = args.health
health_note = "(--health)" if health else "(disabled via --health)"
else:
existing_health = extract_existing_health(project_dir)
if existing_health is not _HEALTH_NOT_FOUND:
# Explicitly configured (path string, possibly empty to disable)
health = existing_health
health_note = "(kept for upgrade)"
else:
# Not found - use default
health = DEFAULT_HEALTH
health_note = "(--health to override)"
if health:
print(f"🏥 Health check: {health} {health_note}")
else:
print(f"🏥 Health check: disabled {health_note}")
# Title for templates
project_title = module_name.replace("_", " ").title()
# Find existing FastAPI app
app_info = find_fastapi_app(module_dir, project_dir) if module_dir.exists() else None
# Template variables
tpl_vars = {
"MODULE_NAME": module_name,
"PROJECT_TITLE": project_title,
"TEMPLATE_DEFAULT_PORT": str(default_port),
"TEMPLATE_VITE_PORT": str(vite_port),
"TEMPLATE_DEV_PORT": str(dev_port),
"TEMPLATE_HEALTH": f'"{health}"',
"ENVPREFIX": module_name.upper(),
"PROJECT_CLI": module_name,
"MAIN_MODULE": main_module_path,
}
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
# Dotted module path relative to project dir (e.g. "{module_name}.api.main")
app_module = ".".join(app_file.relative_to(project_dir).with_suffix("").parts)
tpl_vars["APP_MODULE"] = app_module
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"] = f"{module_name}.app"
# Create directories
if not dry:
if not module_dir.exists():
module_dir.mkdir(parents=True)
fastapi_vue_scripts.mkdir(parents=True, exist_ok=True)
# === Install scripts (always update our own scripts) ===
# Scripts in fastapi-vue folder are internal and always overwritten
# devserver.py can be customized, so use fallback if needed
# Remove obsolete util.py if present
obsolete_util = fastapi_vue_scripts / "util.py"
if obsolete_util.exists():
if dry:
print(f"🗑️ Would remove obsolete {obsolete_util}")
else:
obsolete_util.unlink()
print(f"🗑️ Removed obsolete {obsolete_util}")
# Remove obsolete build-frontend.py if present (renamed to buildhook.py)
obsolete_build_hook = fastapi_vue_scripts / "build-frontend.py"
if obsolete_build_hook.exists():
if dry:
print(f"🗑️ Would remove obsolete {obsolete_build_hook}")
else:
obsolete_build_hook.unlink()
print(f"🗑️ Removed obsolete {obsolete_build_hook}")
# Remove obsolete __init__.py if present (folder is no longer a module)
obsolete_init = fastapi_vue_scripts / "__init__.py"
if obsolete_init.exists():
if dry:
print(f"🗑️ Would remove obsolete {obsolete_init}")
else:
obsolete_init.unlink()
print(f"🗑️ Removed obsolete {obsolete_init}")
# Copy all files from the template's fastapi-vue folder
template_fastapi_vue_dir = TEMPLATE_DIR / "scripts" / "fastapi-vue"
for template_file in sorted(template_fastapi_vue_dir.iterdir()):
if template_file.is_file():
dest_path = fastapi_vue_scripts / template_file.name
template = template_file.read_text("UTF-8")
content = render_template(template, **tpl_vars)
write_file(
dest_path,
content,
overwrite=True,
dry=dry,
force=True, # Internal files, always overwrite
)
# devserver.py - use fallback if customized by user
devserver_path = scripts_dir / "devserver.py"
devserver_fallback = scripts_dir / "devserver.new.py"
template = load_template("scripts/devserver.py")
content = render_template(template, **tpl_vars)
write_file(
devserver_path,
content,
overwrite=True,
dry=dry,
executable=True,
fallback_path=devserver_fallback,
)
# === Handle app module ===
if app_file:
# Existing app: patch with import, route, and try to patch lifespan
patch_app_file(
app_file, main_module_path, app_var, migrate=needs_app_migration(project_dir), dry=dry
)
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=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=dry)
# === Handle __main__.py ===
if existing_cli_module:
# Existing CLI entrypoint - write .new.py beside the existing main module
main_fallback = main.with_suffix(".new.py")
else:
main_fallback = module_dir / "__main__.new.py"
main_file = module_dir / "__main__.py"
template = load_template("backend/__main__.py")
main_content = render_template(template, **tpl_vars)
if main_file.exists():
# Spot-patch the pre-1.6 DEVMODE mechanism in place first - the
# auto-upgrade marker guards full-file overwrites, but leaving this
# change to a .new.py merge would silently break dev mode
migrated = _patch_main_devmode(main_file, dry=dry)
existing = migrated if migrated is not None else main_file.read_text("UTF-8")
# Update if it has the auto-upgrade marker, otherwise use fallback -
# unless the markerless file is otherwise up to date (e.g. the
# DEVMODE spot-patch was the only change), then no fallback is needed
if UPGRADE_MARKER not in existing and _strip_upgrade_marker(
existing
) == _strip_upgrade_marker(ruff_format_content(main_content, main_file)):
print(f"✔️ {main_file} (already up to date)")
else:
write_file(
main_file,
main_content,
overwrite=True,
dry=dry,
fallback_path=main_fallback,
)
elif not existing_cli_module:
# No file and no existing entrypoint: create new __main__.py
write_file(
main_file,
main_content,
overwrite=False,
dry=dry,
)
else:
# Existing CLI entrypoint: write our template as .new.py beside the existing module
# and also patch the existing module with FASTAPI_VUE setup if needed
_write_fallback_file(
main,
main_fallback,
main_content,
dry=dry,
executable=False,
)
if main.exists():
migrated = _patch_main_devmode(main, dry=dry)
content = migrated if migrated is not None else main.read_text("UTF-8")
if "FASTAPI_VUE" not in content:
new_content = _add_env_prefix_to_main(content)
new_file = main.with_suffix(".new.py")
_write_fallback_file(
main,
new_file,
new_content,
dry=dry,
executable=False,
)
else:
print(f"⚠️ Existing CLI main module {main} not found")
# === 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"
# Upgrade old plugin versions that lack the auto-upgrade marker
_upgrade_old_vite_plugin(plugin_file, module_name, dry=dry)
template = load_template("frontend/vite-plugin-fastapi.js")
content = render_template(template, **tpl_vars)
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, dry=dry)
elif vite_config_js.exists():
patch_vite_config(vite_config_js, dry=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=dry)
# === Update pyproject.toml ===
pyproject_path = project_dir / "pyproject.toml"
if pyproject_path.exists():
old_content = pyproject_path.read_text("UTF-8")
data = tomlkit.parse(old_content)
updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name)
# Only add script entry if project doesn't already have scripts
if "scripts" not in updated["project"] or not updated["project"]["scripts"]:
updated["project"]["scripts"] = tomlkit.table()
script_name = module_name.replace("_", "-")
updated["project"]["scripts"][script_name] = f"{module_name}.__main__:main"
# Check if content actually changed
new_content = tomlkit.dumps(updated)
if new_content == old_content:
print(f"✔️ {pyproject_path} (already up to date)")
elif dry:
print(f"✅ Would update {pyproject_path}")
else:
pyproject_path.write_text(new_content, "UTF-8", newline="\n")
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_bytes()
if b"frontend-build" in gitignore_content:
print("✔️ .gitignore (frontend-build already ignored)")
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"
suffix = b"" if gitignore_content.endswith(nl) else nl
gitignore_path.write_bytes(gitignore_content + suffix + gitignore_entry.encode() + nl)
print(f"✅ Added {gitignore_entry} to .gitignore")
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 ===
# Pin fastapi-vue to the same major.minor.patch as this setup tool (both are
# released from the same tags). This makes freshly set up projects request the
# matching patch release directly, while `~=` still allows compatible updates.
mmp = re.match(r"(\d+)\.(\d+)\.(\d+)", version)
fastapi_vue_req = f"fastapi-vue~={mmp[1]}.{mmp[2]}.{mmp[3]}" if mmp else "fastapi-vue"
if dry:
print(f"📦 Would add: fastapi[standard], {fastapi_vue_req}")
print("📦 Would run: uv sync")
else:
print("📦 Dependencies")
uv_add_packages(["fastapi[standard]", fastapi_vue_req], cwd=project_dir)
# uv add runs with --frozen, so lock and sync the environment once
# everything is in place; attached to the terminal so the user sees
# the updates, and non-fatal - setup is complete either way
result = subprocess.run(["uv", "sync"], cwd=project_dir, check=False) # noqa: S607
if result.returncode != 0:
print("⚠️ uv sync failed - run it manually to update the environment")
print()
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} && "
script_name = module_name.replace("_", "-")
message = SETUP_COMPLETE_MESSAGE.replace("CD_CMD", cd_cmd).replace("SCRIPT_NAME", script_name)
if platform.system() == "Windows":
message = message.replace(" && ", "; ")
print(message)
# Show merge note if any .new.py files were written
if _new_files_written:
print()
print("⚠️ Some files could not be auto-upgraded because you customized them.")
print(" Please manually merge the following files:")
for new_file, original_file in _new_files_written:
try:
new_rel = new_file.relative_to(project_dir)
orig_rel = original_file.relative_to(project_dir)
except ValueError:
new_rel = new_file.name
orig_rel = original_file.name
print(f" • {new_rel}{orig_rel}")
print()
# Clear the list for potential subsequent runs
_new_files_written.clear()
return 0
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 for vite plugin in frontend
scriptdir = path / "scripts" / "fastapi-vue"
viteplugin = path / "frontend" / "vite-plugin-fastapi.js"
return scriptdir.exists() or viteplugin.exists()
def main() -> int:
"""CLI entry point."""
parser = argparse.ArgumentParser(
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 Preview what would be done
fastapi-vue-setup . --ports=8000,5173,8080 Change default ports (backend, vite dev, backend dev)
fastapi-vue-setup my-app -- --default Non-interactive create-vue (extra args after --
are forwarded to create-vue, e.g. --default, --ts)
""",
)
parser.add_argument(
"project_dir",
nargs="?",
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="BACKEND,VITE,DEV",
help="Port configuration as comma-separated values (default: 3100,3100,3200)",
)
parser.add_argument(
"--health",
metavar="PATH",
help='Health check endpoint (disable waiting for backend startup by setting "")',
)
parser.add_argument("--dry", "--dry-run", action="store_true", help="Show what would be done")
# Everything after a standalone `--` is forwarded verbatim to create-vue
argv = sys.argv[1:]
if "--" in argv:
split = argv.index("--")
ours, vue_args = argv[:split], argv[split + 1 :]
else:
ours, vue_args = argv, []
args = parser.parse_args(ours)
args.vue_args = vue_args
if args.project_dir is None:
parser.print_help()
return 0
return cmd_setup(args)
if __name__ == "__main__":
sys.exit(main())