Compare commits

...
5 Commits
4 changed files with 434 additions and 50 deletions
+38 -41
View File
@@ -119,7 +119,7 @@ Next steps:
def load_template(path: str) -> str: def load_template(path: str) -> str:
"""Load a template file from the template directory.""" """Load a template file from the template directory."""
return (TEMPLATE_DIR / path).read_text() return (TEMPLATE_DIR / path).read_text("UTF-8")
def find_module_name(project_dir: Path) -> str | None: def find_module_name(project_dir: Path) -> str | None:
@@ -128,7 +128,7 @@ def find_module_name(project_dir: Path) -> str | None:
if not pyproject.exists(): if not pyproject.exists():
return None return None
data = tomlkit.parse(pyproject.read_text()) data = tomlkit.parse(pyproject.read_text("UTF-8"))
if "project" in data and "name" in data["project"]: if "project" in data and "name" in data["project"]:
name = data["project"]["name"] name = data["project"]["name"]
@@ -190,7 +190,7 @@ def _find_app_via_entrypoint(
return None return None
try: try:
data = tomlkit.parse(pyproject.read_text()) data = tomlkit.parse(pyproject.read_text("UTF-8"))
except Exception: except Exception:
return None return None
@@ -258,7 +258,7 @@ def _find_existing_cli_entrypoint(project_dir: Path, module_name: str) -> str |
return None return None
try: try:
data = tomlkit.parse(pyproject.read_text()) data = tomlkit.parse(pyproject.read_text("UTF-8"))
except Exception: except Exception:
return None return None
@@ -282,7 +282,7 @@ def _follow_init_reexport(init_file: Path, subpkg_dir: Path) -> tuple[Path, str]
- from module.subpkg.mainapp import app - from module.subpkg.mainapp import app
""" """
try: try:
content = init_file.read_text() content = init_file.read_text("UTF-8")
except Exception: except Exception:
return None return None
@@ -316,7 +316,7 @@ def _follow_init_reexport(init_file: Path, subpkg_dir: Path) -> tuple[Path, str]
def _find_app_in_file(path: Path) -> str | None: def _find_app_in_file(path: Path) -> str | None:
"""Find FastAPI app variable name in a file.""" """Find FastAPI app variable name in a file."""
try: try:
content = path.read_text() content = path.read_text("UTF-8")
except Exception: except Exception:
return None return None
@@ -351,7 +351,7 @@ def patch_app_file(
print(f"❌ Cannot patch {path} - file not found") print(f"❌ Cannot patch {path} - file not found")
return False return False
original_content = path.read_text() original_content = path.read_text("UTF-8")
marker = "from fastapi_vue import Frontend" marker = "from fastapi_vue import Frontend"
if marker in original_content: if marker in original_content:
@@ -418,7 +418,7 @@ def patch_app_file(
print(f"[DRY RUN] Would patch {path}") print(f"[DRY RUN] Would patch {path}")
return True return True
path.write_text(content) path.write_text(content, "UTF-8", newline="\n")
print(f"✅ Patched {path}") print(f"✅ Patched {path}")
if not lifespan_patched: if not lifespan_patched:
@@ -453,7 +453,7 @@ def patch_vite_config(
print(f"❌ Cannot patch {path} - file not found") print(f"❌ Cannot patch {path} - file not found")
return False return False
original_content = path.read_text() original_content = path.read_text("UTF-8")
marker = "vite-plugin-fastapi" marker = "vite-plugin-fastapi"
if marker in original_content: if marker in original_content:
@@ -508,7 +508,7 @@ def patch_vite_config(
print(f"[DRY RUN] Would patch {path}") print(f"[DRY RUN] Would patch {path}")
return True return True
path.write_text(content) path.write_text(content, "UTF-8", newline="\n")
print(f"✅ Patched {path}") print(f"✅ Patched {path}")
return True return True
@@ -542,7 +542,7 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
print("⚠️ No Vue file found to patch, skipping frontend health check") print("⚠️ No Vue file found to patch, skipping frontend health check")
return False return False
original_content = target_file.read_text() original_content = target_file.read_text("UTF-8")
# Check if already patched # Check if already patched
if "/api/health" in original_content: if "/api/health" in original_content:
@@ -608,7 +608,7 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
print(f"[DRY RUN] Would patch {target_file}") print(f"[DRY RUN] Would patch {target_file}")
return True return True
target_file.write_text(content) target_file.write_text(content, "UTF-8", newline="\n")
print(f"✅ Patched {target_file}") print(f"✅ Patched {target_file}")
return True return True
@@ -640,7 +640,7 @@ def write_file(
# Check if content is the same # Check if content is the same
if exists: if exists:
existing_content = path.read_text() existing_content = path.read_text("UTF-8")
if existing_content == content: if existing_content == content:
print(f"✔️ {path} (already up to date)") print(f"✔️ {path} (already up to date)")
return False return False
@@ -661,7 +661,7 @@ def write_file(
return True return True
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content) path.write_text(content, "UTF-8", newline="\n")
if executable and sys.platform != "win32": if executable and sys.platform != "win32":
path.chmod(path.stat().st_mode | 0o111) path.chmod(path.stat().st_mode | 0o111)
action = "Updated" if exists else "Created" action = "Updated" if exists else "Created"
@@ -679,7 +679,7 @@ def _write_fallback_file(
"""Write content to a fallback .new.py file when original can't be overwritten.""" """Write content to a fallback .new.py file when original can't be overwritten."""
# Check if fallback already has same content # Check if fallback already has same content
if fallback_path.exists(): if fallback_path.exists():
if fallback_path.read_text() == content: if fallback_path.read_text("UTF-8") == content:
print(f"✔️ {fallback_path} (already up to date)") print(f"✔️ {fallback_path} (already up to date)")
return False return False
@@ -689,7 +689,7 @@ def _write_fallback_file(
return True return True
fallback_path.parent.mkdir(parents=True, exist_ok=True) fallback_path.parent.mkdir(parents=True, exist_ok=True)
fallback_path.write_text(content) fallback_path.write_text(content, "UTF-8", newline="\n")
if executable and sys.platform != "win32": if executable and sys.platform != "win32":
fallback_path.chmod(fallback_path.stat().st_mode | 0o111) fallback_path.chmod(fallback_path.stat().st_mode | 0o111)
print(f"✅ Created {fallback_path} (original customized by user)") print(f"✅ Created {fallback_path} (original customized by user)")
@@ -896,6 +896,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
- Patching existing projects with integration files - Patching existing projects with integration files
- Updating already-patched projects - 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) project_path = Path(args.project_dir)
# Handle both "." and "/path/to/project" # Handle both "." and "/path/to/project"
@@ -991,7 +994,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
for template_file in template_fastapi_vue_dir.iterdir(): for template_file in template_fastapi_vue_dir.iterdir():
if template_file.is_file(): if template_file.is_file():
dest_path = fastapi_vue_scripts / template_file.name dest_path = fastapi_vue_scripts / template_file.name
template = template_file.read_text() template = template_file.read_text("UTF-8")
content = render_template(template, **tpl_vars) content = render_template(template, **tpl_vars)
write_file( write_file(
dest_path, dest_path,
@@ -1089,7 +1092,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
# === Update pyproject.toml === # === Update pyproject.toml ===
pyproject_path = project_dir / "pyproject.toml" pyproject_path = project_dir / "pyproject.toml"
if pyproject_path.exists(): if pyproject_path.exists():
old_content = pyproject_path.read_text() old_content = pyproject_path.read_text("UTF-8")
data = tomlkit.parse(old_content) data = tomlkit.parse(old_content)
updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name) updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name)
@@ -1108,30 +1111,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
elif dry_run: elif dry_run:
print(f"[DRY RUN] Would update {pyproject_path}") print(f"[DRY RUN] Would update {pyproject_path}")
else: else:
pyproject_path.write_text(new_content) pyproject_path.write_text(new_content, "UTF-8", newline="\n")
print(f"✅ Updated {pyproject_path}") print(f"✅ Updated {pyproject_path}")
# === Add dependencies using uv ===
if dry_run:
print("[DRY RUN] Would run: uv add -U 'fastapi[standard]' fastapi-vue")
print("[DRY RUN] Would run: uv add -U --group dev httpx")
else:
print("📦 Adding dependencies...")
result = subprocess.run(
["uv", "add", "-U", "fastapi[standard]", "fastapi-vue"],
cwd=project_dir,
check=False,
)
if result.returncode != 0:
print("⚠️ Failed to add main dependencies")
result = subprocess.run(
["uv", "add", "-U", "--group", "dev", "httpx"],
cwd=project_dir,
check=False,
)
if result.returncode != 0:
print("⚠️ Failed to add dev dependencies")
# === Update .gitignore === # === Update .gitignore ===
gitignore_path = project_dir / ".gitignore" gitignore_path = project_dir / ".gitignore"
gitignore_entry = f"/{module_name}/frontend-build" gitignore_entry = f"/{module_name}/frontend-build"
@@ -1151,9 +1133,24 @@ def cmd_setup(args: argparse.Namespace) -> int:
elif dry_run: elif dry_run:
print(f"[DRY RUN] Would create .gitignore with {gitignore_entry}") print(f"[DRY RUN] Would create .gitignore with {gitignore_entry}")
else: else:
gitignore_path.write_text(f"{gitignore_entry}\n") gitignore_path.write_text(f"{gitignore_entry}\n", "UTF-8", newline="\n")
print("✅ Created .gitignore") 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")
print() print()
print("=" * 60) print("=" * 60)
print("✅ Setup complete!") print("✅ Setup complete!")
+8 -9
View File
@@ -13,6 +13,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue"))) sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from devutil import ( # type: ignore from devutil import ( # type: ignore
ProcessGroup, ProcessGroup,
check_ports_free,
logger, logger,
ready, ready,
setup_fastapi, setup_fastapi,
@@ -27,16 +28,16 @@ async def run_devserver(frontend: str, backend: str) -> None:
logger.warning("Frontend source not found at %s", front) logger.warning("Frontend source not found at %s", front)
raise SystemExit(1) raise SystemExit(1)
frontend_url, npm_install, vite = setup_vite(frontend) viteurl, npm_install, vite = setup_vite(frontend)
backend_url, fastapi = setup_fastapi(backend, "MODULE_NAME.APP_MODULE:APP_VAR") backurl, fastapi = setup_fastapi(backend, "MODULE_NAME.APP_MODULE:APP_VAR")
# Tell the everyone where the frontend and backend are (vite proxy, etc) # Tell the everyone where the frontend and backend are (vite proxy, etc)
os.environ["FASTAPI_VUE_FRONTEND_URL"] = frontend_url os.environ["FASTAPI_VUE_FRONTEND_URL"] = viteurl
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url os.environ["FASTAPI_VUE_BACKEND_URL"] = backurl
async with ProcessGroup() as pg: async with ProcessGroup() as pg:
install_proc = await pg.spawn(*npm_install, cwd=front) npm_i = await pg.spawn(*npm_install, cwd=front)
await asyncio.sleep(0.2) # reduce message overlap await check_ports_free(viteurl, backurl)
await pg.spawn( await pg.spawn(
*fastapi, *fastapi,
"--reload", "--reload",
@@ -46,9 +47,7 @@ async def run_devserver(frontend: str, backend: str) -> None:
) )
# Wait for both install and backend to be ready # Wait for both install and backend to be ready
async with asyncio.TaskGroup() as tg: await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
tg.create_task(pg.wait(install_proc))
tg.create_task(ready(backend_url, path="/api/health?from=devserver.py"))
# Start Vite dev server (ProcessGroup waits for any exit, then terminates others) # Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
await pg.spawn(*vite, cwd=front) await pg.spawn(*vite, cwd=front)
+191
View File
@@ -0,0 +1,191 @@
"""Utilities used at build time and in devserver script. No dependencies."""
import logging
import os
import re
import shutil
import subprocess
from pathlib import Path
class _PrefixFormatter(logging.Formatter):
"""Formatter that adds prefix based on log level."""
def format(self, record: logging.LogRecord) -> str:
if record.levelno >= logging.WARNING:
return f"⚠️ {record.getMessage()}"
return record.getMessage()
_handler = logging.StreamHandler()
_handler.setFormatter(_PrefixFormatter())
logger = logging.getLogger("fastapi-vue")
logger.addHandler(_handler)
logger.setLevel(logging.INFO)
def _check_node_version(node_path: str) -> None:
"""Check if Node.js version is >= 20.
Raises RuntimeError if version is too old or cannot be determined.
"""
try:
result = subprocess.run(
[node_path, "--version"], capture_output=True, text=True, check=True
)
version_str = result.stdout.strip()
# Parse version like "v20.10.0" or "v18.17.1"
match = re.match(r"v(\d+)", version_str)
if match:
major_version = int(match.group(1))
if major_version >= 20:
return
raise RuntimeError(
f"Node.js {version_str} found, but v20+ required (install with nvm)"
)
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
pass
raise RuntimeError("Could not determine Node.js version")
def find_js_runtime() -> tuple[str, str]:
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Raises JSRuntimeError if no suitable runtime is found.
"""
options = ["npm", "deno", "bun"]
node_version_error: RuntimeError | None = None
# 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:
raise RuntimeError(
f"JS_RUNTIME={js_runtime_env}: {option} not found"
)
# Check Node.js version if using npm
if option == "npm":
node_path = shutil.which("node", path=str(Path(tool).parent))
if node_path is None:
raise RuntimeError(
f"JS_RUNTIME={js_runtime_env}: node not found"
)
_check_node_version(node_path) # Raises on failure
return tool, option
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
# Auto-detect
for option in options:
if tool := shutil.which(option):
# Check Node.js version if using npm
if option == "npm":
node_path = shutil.which("node", path=str(Path(tool).parent))
if node_path is None:
continue
try:
_check_node_version(node_path)
except RuntimeError as e:
node_version_error = e
continue # Try next runtime
return tool, option
# No runtime found - provide helpful error
if node_version_error:
raise node_version_error
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
def find_build_tool():
"""Find JavaScript runtime and construct install/build commands.
Returns (install_cmd, build_cmd) tuples of command lists.
Raises RuntimeError if no runtime is found.
"""
install = {
"deno": ("install", "--allow-scripts=npm:vue-demi"),
"npm": ("install",),
"bun": ("--bun", "install"),
}
# Run vite directly for deno to avoid npm-run-all2/run-p issues
build = {
"deno": ("run", "-A", "npm:vite", "build"),
"npm": ("run", "build"),
"bun": ("--bun", "run", "build"),
}
tool, name = find_js_runtime()
return [tool, *install[name]], [tool, *build[name]]
def find_dev_tool() -> list[str]:
"""Find JavaScript runtime and construct dev command.
Returns dev_cmd (without vite-specific args).
Raises RuntimeError if no runtime is found.
"""
dev_args = {
"deno": ("run", "dev", "--"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}
tool, name = find_js_runtime()
if name == "bun":
logger.warning(
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
)
return [tool, *dev_args[name]]
def find_install_tool() -> list[str]:
"""Find JavaScript runtime and construct install command.
Returns install_cmd.
Raises RuntimeError if no runtime is found.
"""
install_args = {
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
"npm": ("install", "--silent"),
"bun": ("install", "--silent"),
}
tool, name = find_js_runtime()
return [tool, *install_args[name]]
def build(folder: str = "frontend") -> None:
"""Build the frontend in the specified folder.
Raises SystemExit(1) on failure.
"""
logger.info(">>> Building %s", folder)
try:
install_cmd, build_cmd = find_build_tool()
except RuntimeError as e:
logger.warning(e)
raise SystemExit(1)
def run(cmd):
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
logger.info("### %s", " ".join(display_cmd))
subprocess.run(cmd, check=True, cwd=folder)
try:
run(install_cmd)
logger.info("")
run(build_cmd)
except subprocess.CalledProcessError:
raise SystemExit(1)
+197
View File
@@ -0,0 +1,197 @@
"""Utilities meant for devserver script, used only in source repository with dev deps."""
import asyncio
import subprocess
from collections.abc import Coroutine
from pathlib import Path
from typing import Any
import httpx
from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint
DEFAULT_VITE_PORT = 5173
DEFAULT_BACKEND_PORT = 5180
class ProcessGroup:
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
def __init__(self):
self._procs: list[asyncio.subprocess.Process] = []
self._cmds: dict[int, str] = {} # pid -> command name
async def spawn(
self, *cmd: str, cwd: str | None = None
) -> asyncio.subprocess.Process:
"""Spawn a subprocess and track it."""
cmd_name = Path(cmd[0]).name
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._procs.append(proc)
self._cmds[proc.pid] = cmd_name
return proc
async def wait(
self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]"
) -> None:
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
returncode = await proc.wait()
if returncode != 0:
cmd_name = self._cmds.get(proc.pid, "unknown")
raise subprocess.CalledProcessError(returncode, cmd_name)
tasks = [
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
for w in waitables
]
try:
await asyncio.gather(*tasks)
except subprocess.CalledProcessError as e:
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
raise SystemExit(1) from None
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, *_):
"""Wait for one process to exit, terminate others, then wait for all."""
cleanup_task = asyncio.create_task(
self._cleanup(immediate=exc_type is not None)
)
try:
await asyncio.shield(cleanup_task)
except asyncio.CancelledError:
# Shield was cancelled but cleanup_task continues - wait for it
await cleanup_task
async def _cleanup(self, immediate: bool = False):
running = [p for p in self._procs if p.returncode is None]
if not running:
return
if not immediate:
# Wait for any one process to exit
await asyncio.wait(
[asyncio.create_task(p.wait()) for p in running],
return_when=asyncio.FIRST_COMPLETED,
)
# Terminate remaining processes
for p in self._procs:
if p.returncode is None:
try:
p.terminate()
except ProcessLookupError:
pass
# Wait for all to finish (with overall timeout)
still_running = [p for p in self._procs if p.returncode is None]
if still_running:
try:
await asyncio.wait_for(
asyncio.gather(*[p.wait() for p in still_running]),
timeout=10,
)
except TimeoutError:
for p in self._procs:
if p.returncode is None:
try:
p.kill()
except ProcessLookupError:
pass
await p.wait()
async def check_ports_free(*urls: str) -> None:
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
async with httpx.AsyncClient() as client:
for url in urls:
try:
res = await client.get(url, timeout=0.1)
logger.warning(
"Conflicting %s already running at %s",
res.headers.get("server", "server"),
url,
)
raise SystemExit(1)
except httpx.RequestError:
pass # Expected - port is free
async def ready(url: str, path: str = "") -> None:
"""Wait for the server to be ready by polling an endpoint.
Raises SystemExit(1) if server doesn't start in time.
"""
max_attempts = 50
full_url = f"{url}{path}"
async with httpx.AsyncClient() as client:
for attempt in range(max_attempts):
try:
await client.get(full_url, timeout=1.0)
logger.info("✓ Backend ready!")
return
except httpx.RequestError:
if attempt == max_attempts - 1:
logger.warning("Backend didn't start in time")
raise SystemExit(1)
await asyncio.sleep(0.1)
def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]:
"""Parse frontend endpoint and build commands.
Returns (url, install_cmd, dev_cmd).
Raises SystemExit(1) on invalid config.
"""
endpoints = parse_endpoint(endpoint, DEFAULT_VITE_PORT)
if "uds" in endpoints[0]:
logger.warning("Unix sockets not supported with vite devserver")
raise SystemExit(1)
port = endpoints[0]["port"]
host = endpoints[0]["host"]
install_cmd = find_install_tool()
dev_cmd = find_dev_tool()
if host != "localhost":
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
if port != 5173:
dev_cmd.append(f"--port={port}")
return f"http://{host}:{port}", install_cmd, dev_cmd
def setup_fastapi(
endpoint: str, module: str, default_port: int = DEFAULT_BACKEND_PORT
) -> tuple[str, list[str]]:
"""Parse backend endpoint and build fastapi dev command.
Returns (url, 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 = [
"fastapi",
"dev",
"--entrypoint",
module,
"--host",
host,
"--port",
str(port),
]
return f"http://{host}:{port}", cmd