Added automatic handling of FastAPI debug mode. Use templating more completely to enable project name as env prefix etc.

This commit is contained in:
2026-02-06 19:05:36 +00:00
parent fbed7873e5
commit 4bf102579a
7 changed files with 201 additions and 85 deletions
+133 -49
View File
@@ -12,6 +12,7 @@ Options:
"""
import argparse
import ast
import os
import re
import shutil
@@ -22,9 +23,49 @@ from textwrap import indent
import tomlkit
# Track Python files written/patched for ruff formatting
_python_files_to_format: list[Path] = []
# Template directory
TEMPLATE_DIR = Path(__file__).parent / "template"
def ruff_sort_imports(files: list[Path], dry_run: 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:
print(f"[DRY RUN] 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],
stdout=subprocess.DEVNULL,
)
def uv_add_packages(
packages: list[str], *, cwd: Path, group: str | None = None, dry_run: bool = False
) -> None:
"""Add packages using uv."""
cmd = ["uv", "add", "-q", "-U"]
if group:
cmd.extend(["--group", group])
else:
cmd.append("--no-sync")
cmd.extend(packages)
if dry_run:
print(f"[DRY RUN] Would run: {' '.join(cmd)}")
return
result = subprocess.run(cmd, cwd=cwd, check=False)
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)
@@ -153,6 +194,24 @@ def parse_ports(ports_str: str | None) -> tuple[int, int, int]:
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) -> tuple[int, int, int] | None:
"""Extract existing port configuration from project files.
@@ -421,7 +480,7 @@ def patch_app_file(
) -> bool:
"""Patch an existing app.py with frontend integration.
Inserts import and Frontend instantiation after imports, route at bottom,
Inserts imports at top (ruff will sort them), route at bottom,
and tries to patch lifespan with frontend.load().
Returns True if patched, False if already patched or failed.
@@ -431,60 +490,92 @@ def patch_app_file(
return False
original_content = path.read_text("UTF-8")
marker = "from fastapi_vue import Frontend"
content = original_content
if marker in original_content:
# Check what's already patched
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
if has_frontend and has_devmode and has_debug_arg:
print(f"✔️ {path} (already patched)")
return False
# Find where to insert the import (after other imports)
lines = original_content.split("\n")
import_line = "from fastapi_vue import Frontend"
route_line = f'frontend.route({app_var}, "/")'
# Find last import line and check if pathlib is imported
# Add missing imports (using AST to find correct insertion point)
imports = []
if not has_frontend:
imports.extend(["from pathlib import Path", "from fastapi_vue import Frontend"])
if not has_devmode:
imports.append(f"from {module_name}.__main__ import DEVMODE")
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
has_pathlib = False
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("import ") or stripped.startswith("from "):
last_import_idx = i
if "pathlib" in stripped or "from pathlib" in stripped:
has_pathlib = True
elif stripped and not stripped.startswith("#") and last_import_idx > 0:
# Stop at first non-import, non-comment, non-empty line after imports
break
# Check if we found imports to insert after
if last_import_idx == 0 and not lines[0].strip().startswith(("import ", "from ")):
print(f"⚠️ Skipping {path} (no imports found to patch)")
return False
# Insert imports after last import, then frontend instantiation
if not has_pathlib:
lines.insert(last_import_idx + 1, "from pathlib import Path")
last_import_idx += 1
lines.insert(last_import_idx + 1, import_line)
lines.insert(last_import_idx + 2, FRONTEND_BLOCK)
lines.insert(last_import_idx + 1, FRONTEND_BLOCK)
# Append route at end
lines.append("")
lines.append("# Serve the Vue frontend (needs to be last if SPA catch-all is used)")
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=DEVMODE 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=DEVMODE as first argument
if args.strip():
new_args = f"debug=DEVMODE, {args}"
else:
new_args = "debug=DEVMODE"
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 = False
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:
indent = yield_match.group(1)
ws = yield_match.group(1)
insert_pos = yield_match.start()
load_code = f"{indent}await frontend.load()\n"
load_code = f"{ws}await frontend.load()\n"
content = content[:insert_pos] + load_code + content[insert_pos:]
lifespan_patched = True
@@ -498,6 +589,7 @@ def patch_app_file(
return True
path.write_text(content, "UTF-8", newline="\n")
_python_files_to_format.append(path)
print(f"✅ Patched {path}")
if not lifespan_patched:
@@ -743,6 +835,8 @@ def write_file(
path.write_text(content, "UTF-8", newline="\n")
if executable and sys.platform != "win32":
path.chmod(path.stat().st_mode | 0o111)
if path.suffix == ".py":
_python_files_to_format.append(path)
action = "Updated" if exists else "Created"
print(f"{action} {path}")
return True
@@ -771,6 +865,8 @@ def _write_fallback_file(
fallback_path.write_text(content, "UTF-8", newline="\n")
if executable and sys.platform != "win32":
fallback_path.chmod(fallback_path.stat().st_mode | 0o111)
if fallback_path.suffix == ".py":
_python_files_to_format.append(fallback_path)
print(f"✅ Created {fallback_path} (original customized by user)")
_new_files_written.append((fallback_path, original_path))
return True
@@ -1047,6 +1143,8 @@ def cmd_setup(args: argparse.Namespace) -> int:
"TEMPLATE_DEFAULT_PORT": str(default_port),
"TEMPLATE_VITE_PORT": str(vite_port),
"TEMPLATE_DEV_PORT": str(dev_port),
"ENVPREFIX": module_name.upper(),
"PROJECT_CLI": module_name,
}
module_dir = project_dir / module_name
@@ -1239,27 +1337,13 @@ def cmd_setup(args: argparse.Namespace) -> int:
print("✅ Created .gitignore")
# === Add dependencies using uv ===
uv_add_main = [
"uv",
"add",
"-q",
"-U",
"--no-sync",
"fastapi[standard]",
"fastapi-vue",
]
uv_add_dev = ["uv", "add", "-q", "-U", "--group", "dev", "httpx"]
if dry_run:
print(f"[DRY RUN] Would run: {' '.join(uv_add_main)}")
print(f"[DRY RUN] Would run: {' '.join(uv_add_dev)}")
else:
print("📦 Adding dependencies...")
result = subprocess.run(uv_add_main, cwd=project_dir, check=False)
if result.returncode != 0:
print("⚠️ Failed to add main dependencies")
result = subprocess.run(uv_add_dev, cwd=project_dir, check=False)
if result.returncode != 0:
print("⚠️ Failed to add dev dependencies")
ruff_sort_imports(_python_files_to_format, dry_run=dry_run)
if not dry_run:
print("📦 Dependencies")
uv_add_packages(
["fastapi[standard]", "fastapi-vue"], cwd=project_dir, dry_run=dry_run
)
uv_add_packages(["httpx"], cwd=project_dir, group="dev", dry_run=dry_run)
print()
print("=" * 60)
+5 -1
View File
@@ -9,6 +9,7 @@ description = "Tool to create or patch FastAPI+Vue projects with integrated buil
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"ruff>=0.14.13",
"tomlkit>=0.12.0",
]
@@ -26,4 +27,7 @@ source = "vcs"
include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"]
[dependency-groups]
dev = ["ruff"]
dev = ["ruff", "fastapi-vue"]
[tool.uv.sources]
fastapi-vue = { path = "fastapi-vue", editable = true }
+3
View File
@@ -1,9 +1,11 @@
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
import argparse
import os
from fastapi_vue import server
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
DEVMODE = bool(os.getenv("ENVPREFIX_FRONTEND_URL"))
def main():
@@ -19,6 +21,7 @@ def main():
"MODULE_NAME.APP_MODULE:APP_VAR",
listen=args.listen,
default_port=DEFAULT_PORT,
reload=DEVMODE,
)
+3 -2
View File
@@ -3,9 +3,10 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi_vue import Frontend
from MODULE_NAME.__main__ import DEVMODE
# Vue Frontend static files
frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"])
frontend = Frontend(Path(__file__).with_name("frontend-build"))
@asynccontextmanager
@@ -15,7 +16,7 @@ async def lifespan(app: FastAPI):
yield
app = FastAPI(title="PROJECT_TITLE", lifespan=lifespan)
app = FastAPI(title="PROJECT_TITLE", debug=DEVMODE, lifespan=lifespan)
# Add API routes here...
+6 -5
View File
@@ -1,17 +1,18 @@
/**
* FastAPI-Vue Vite Plugin
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
*
* Configures Vite for FastAPI backend integration:
* - Proxies /api/* requests to the FastAPI backend
* - Builds to the Python module's frontend-build directory
*
* Environment variables (with defaults):
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
* Options:
* paths - Array of paths to proxy (default: ["/api"])
*/
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
export default function fastapiVue({ paths = ["/api"] } = {}) {
const backendUrl = process.env.ENVPREFIX_BACKEND_URL || "http://localhost:TEMPLATE_DEFAULT_PORT"
// Build proxy configuration for each path
const proxy = {}
for (const path of paths) {
@@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
}
return {
name: "fastapi-vite",
name: "vite-plugin-fastapi-MODULE_NAME",
config: () => ({
server: { proxy },
build: {
+10 -13
View File
@@ -16,7 +16,6 @@ from devutil import ( # type: ignore
check_ports_free,
logger,
ready,
setup_fastapi,
setup_vite,
)
@@ -24,7 +23,9 @@ DEFAULT_VITE_PORT = TEMPLATE_VITE_PORT
DEFAULT_DEV_PORT = TEMPLATE_DEV_PORT
async def run_devserver(frontend: str, backend: str) -> None:
async def run_devserver(
frontend: str, backend: str, extra_args: list[str] | None = None
) -> None:
reporoot = Path(__file__).parent.parent
front = reporoot / "frontend"
if not (front / "package.json").exists():
@@ -32,18 +33,16 @@ async def run_devserver(frontend: str, backend: str) -> None:
raise SystemExit(1)
viteurl, npm_install, vite = setup_vite(frontend, DEFAULT_VITE_PORT)
backurl, uvicorn = setup_fastapi(
backend, "MODULE_NAME.APP_MODULE:APP_VAR", DEFAULT_DEV_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["FASTAPI_VUE_FRONTEND_URL"] = viteurl
os.environ["FASTAPI_VUE_BACKEND_URL"] = backurl
os.environ["ENVPREFIX_FRONTEND_URL"] = viteurl
os.environ["ENVPREFIX_BACKEND_URL"] = backurl
async with ProcessGroup() as pg:
npm_i = await pg.spawn(*npm_install, cwd=front)
await check_ports_free(viteurl, backurl)
await pg.spawn(*uvicorn)
await pg.spawn(*MODULE_NAME, *(extra_args or []))
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
await pg.spawn(*vite, cwd=front)
@@ -65,15 +64,13 @@ def main():
metavar="host:port",
help=f"FastAPI backend endpoint (default: localhost:{DEFAULT_DEV_PORT})",
)
args = parser.parse_args()
args, extra_args = parser.parse_known_args()
with suppress(KeyboardInterrupt):
asyncio.run(run_devserver(args.frontend, args.backend))
asyncio.run(run_devserver(args.frontend, args.backend, extra_args))
HELP_EPILOG = """
scripts/devserver.py # Default ports on localhost
scripts/devserver.py 3000 # Vite on localhost:3000
scripts/devserver.py :3000 --backend 8000 # *:3000, localhost:8000
scripts/devserver.py [args to PROJECT_CLI]
JS_RUNTIME environment variable can be used to select the JS runtime:
npm, deno, bun, or full path to the runtime executable (node maps to npm).
+26
View File
@@ -188,3 +188,29 @@ def setup_fastapi(
"--forwarded-allow-ips=*",
]
return f"http://{host}:{port}", cmd
def setup_cli(
cli: str, endpoint: str, default_port: int = 8000
) -> tuple[str, list[str]]:
"""Parse backend endpoint and build CLI command.
Returns (url, cli_cmd).
Raises SystemExit(1) on invalid config.
"""
endpoints = parse_endpoint(endpoint, default_port)
if "uds" in endpoints[0]:
logger.warning("Unix sockets not supported with vite devserver")
raise SystemExit(1)
host = endpoints[0]["host"]
port = endpoints[0]["port"]
cmd = [
sys.executable,
"-m",
cli,
f"--listen={host}:{port}",
]
return f"http://{host}:{port}", cmd