Compare commits

..
3 Commits
5 changed files with 159 additions and 445 deletions
+98 -80
View File
@@ -13,6 +13,7 @@ Options:
import argparse
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
@@ -118,7 +119,7 @@ Next steps:
def load_template(path: str) -> str:
"""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:
@@ -127,7 +128,7 @@ def find_module_name(project_dir: Path) -> str | None:
if not pyproject.exists():
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"]:
name = data["project"]["name"]
@@ -176,7 +177,9 @@ def find_fastapi_app(
return None
def _find_app_via_entrypoint(module_dir: Path, project_dir: Path) -> tuple[Path, str] | 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"`,
@@ -187,7 +190,7 @@ def _find_app_via_entrypoint(module_dir: Path, project_dir: Path) -> tuple[Path,
return None
try:
data = tomlkit.parse(pyproject.read_text())
data = tomlkit.parse(pyproject.read_text("UTF-8"))
except Exception:
return None
@@ -255,7 +258,7 @@ def _find_existing_cli_entrypoint(project_dir: Path, module_name: str) -> str |
return None
try:
data = tomlkit.parse(pyproject.read_text())
data = tomlkit.parse(pyproject.read_text("UTF-8"))
except Exception:
return None
@@ -266,7 +269,7 @@ def _find_existing_cli_entrypoint(project_dir: Path, module_name: str) -> str |
# Look for any script that references our module
for script_name, entry in scripts.items():
if isinstance(entry, str) and entry.startswith(f"{module_name}."):
return f"{script_name} = \"{entry}\""
return f'{script_name} = "{entry}"'
return None
@@ -279,7 +282,7 @@ def _follow_init_reexport(init_file: Path, subpkg_dir: Path) -> tuple[Path, str]
- from module.subpkg.mainapp import app
"""
try:
content = init_file.read_text()
content = init_file.read_text("UTF-8")
except Exception:
return None
@@ -313,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:
"""Find FastAPI app variable name in a file."""
try:
content = path.read_text()
content = path.read_text("UTF-8")
except Exception:
return None
@@ -348,7 +351,7 @@ def patch_app_file(
print(f"❌ Cannot patch {path} - file not found")
return False
original_content = path.read_text()
original_content = path.read_text("UTF-8")
marker = "from fastapi_vue import Frontend"
if marker in original_content:
@@ -415,7 +418,7 @@ def patch_app_file(
print(f"[DRY RUN] Would patch {path}")
return True
path.write_text(content)
path.write_text(content, "UTF-8", newline="\n")
print(f"✅ Patched {path}")
if not lifespan_patched:
@@ -450,7 +453,7 @@ def patch_vite_config(
print(f"❌ Cannot patch {path} - file not found")
return False
original_content = path.read_text()
original_content = path.read_text("UTF-8")
marker = "vite-plugin-fastapi"
if marker in original_content:
@@ -505,7 +508,7 @@ def patch_vite_config(
print(f"[DRY RUN] Would patch {path}")
return True
path.write_text(content)
path.write_text(content, "UTF-8", newline="\n")
print(f"✅ Patched {path}")
return True
@@ -539,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")
return False
original_content = target_file.read_text()
original_content = target_file.read_text("UTF-8")
# Check if already patched
if "/api/health" in original_content:
@@ -605,7 +608,7 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
print(f"[DRY RUN] Would patch {target_file}")
return True
target_file.write_text(content)
target_file.write_text(content, "UTF-8", newline="\n")
print(f"✅ Patched {target_file}")
return True
@@ -621,11 +624,14 @@ def write_file(
dry_run: 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.
"""
exists = path.exists()
if exists and not overwrite:
@@ -634,13 +640,13 @@ def write_file(
# Check if content is the same
if exists:
existing_content = path.read_text()
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
if overwrite and UPGRADE_MARKER not in existing_content:
# 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(
@@ -655,7 +661,7 @@ def write_file(
return 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":
path.chmod(path.stat().st_mode | 0o111)
action = "Updated" if exists else "Created"
@@ -673,7 +679,7 @@ def _write_fallback_file(
"""Write content to a fallback .new.py file when original can't be overwritten."""
# Check if fallback already has same content
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)")
return False
@@ -683,7 +689,7 @@ def _write_fallback_file(
return 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":
fallback_path.chmod(fallback_path.stat().st_mode | 0o111)
print(f"✅ Created {fallback_path} (original customized by user)")
@@ -720,6 +726,7 @@ def merge_pyproject(
req = data["project"]["requires-python"]
# Parse minimum version from strings like ">=3.10" or ">=3.9,<4"
import re
match = re.search(r">=\s*(\d+)\.(\d+)", req)
if match:
major, minor = int(match.group(1)), int(match.group(2))
@@ -783,8 +790,6 @@ def find_js_runtime() -> tuple[str, str] | None:
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Returns None if no runtime is found.
"""
import shutil
options = ["deno", "npm", "bun"]
# Check for JS_RUNTIME environment variable
@@ -891,6 +896,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
- 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"
@@ -946,7 +954,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
fastapi_vue_scripts = scripts_dir / "fastapi-vue"
# Find existing FastAPI app
app_info = find_fastapi_app(module_dir, project_dir) if module_dir.exists() else None
app_info = (
find_fastapi_app(module_dir, project_dir) if module_dir.exists() else None
)
if app_info:
app_file, app_var = app_info
@@ -962,30 +972,37 @@ def cmd_setup(args: argparse.Namespace) -> int:
# Create directories
if not dry_run:
fastapi_vue_scripts.mkdir(parents=True, exist_ok=True)
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) ===
# util.py and build-frontend.py are internal and always overwritten
# Scripts in fastapi-vue folder are internal and always overwritten
# devserver.py can be customized, so use fallback if needed
internal_script_files = [
(fastapi_vue_scripts / "util.py", "scripts/fastapi-vue/util.py"),
(
fastapi_vue_scripts / "build-frontend.py",
"scripts/fastapi-vue/build-frontend.py",
),
]
for dest_path, template_path in internal_script_files:
template = load_template(template_path)
content = render_template(template, **tpl_vars)
write_file(
dest_path,
content,
overwrite=True,
dry_run=dry_run,
)
# Remove obsolete util.py if present
obsolete_util = fastapi_vue_scripts / "util.py"
if obsolete_util.exists():
if dry_run:
print(f"[DRY RUN] Would remove obsolete {obsolete_util}")
else:
obsolete_util.unlink()
print(f"🗑️ Removed obsolete {obsolete_util}")
# Copy all files from the template's fastapi-vue folder
template_fastapi_vue_dir = TEMPLATE_DIR / "scripts" / "fastapi-vue"
for template_file in 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_run=dry_run,
force=True, # Internal files, always overwrite
)
# devserver.py - use fallback if customized by user
devserver_path = scripts_dir / "devserver.py"
@@ -1021,27 +1038,32 @@ def cmd_setup(args: argparse.Namespace) -> int:
write_file(app_file_path, content, overwrite=False, dry_run=dry_run)
# === Handle __main__.py ===
# Skip if project already has a CLI entrypoint in pyproject.toml
existing_cli = _find_existing_cli_entrypoint(project_dir, module_name)
if existing_cli:
print(f"️ Using existing CLI entrypoint: {existing_cli}")
else:
main_file = module_dir / "__main__.py"
main_fallback = module_dir / "__main__.new.py"
template = load_template("backend/__main__.py")
main_content = render_template(template, **tpl_vars)
main_file = module_dir / "__main__.py"
main_fallback = module_dir / "__main__.new.py"
template = load_template("backend/__main__.py")
main_content = render_template(template, **tpl_vars)
# Use write_file with fallback - it handles all cases:
# - File doesn't exist: create it
# - File exists with marker: update it
# - File exists without marker: write to fallback
# Check if project already has a CLI entrypoint in pyproject.toml
existing_cli = _find_existing_cli_entrypoint(project_dir, module_name)
if main_file.exists():
# File exists: update if it has the auto-upgrade marker, otherwise use fallback
write_file(
main_file,
main_content,
overwrite=True,
overwrite=True,
dry_run=dry_run,
fallback_path=main_fallback,
)
elif not existing_cli:
# No file and no existing entrypoint: create new __main__.py
write_file(
main_file,
main_content,
overwrite=False,
dry_run=dry_run,
)
# else: no file but has existing entrypoint - don't create (user has custom CLI setup)
# === Update vite.config.js/ts ===
frontend_dir = project_dir / "frontend"
@@ -1070,7 +1092,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
# === Update pyproject.toml ===
pyproject_path = project_dir / "pyproject.toml"
if pyproject_path.exists():
old_content = pyproject_path.read_text()
old_content = pyproject_path.read_text("UTF-8")
data = tomlkit.parse(old_content)
updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name)
@@ -1089,30 +1111,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
elif dry_run:
print(f"[DRY RUN] Would update {pyproject_path}")
else:
pyproject_path.write_text(new_content)
pyproject_path.write_text(new_content, "UTF-8", newline="\n")
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 ===
gitignore_path = project_dir / ".gitignore"
gitignore_entry = f"/{module_name}/frontend-build"
@@ -1125,14 +1126,31 @@ def cmd_setup(args: argparse.Namespace) -> int:
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)
gitignore_path.write_bytes(
gitignore_content + suffix + gitignore_entry.encode() + nl
)
print(f"✅ Added {gitignore_entry} to .gitignore")
elif dry_run:
print(f"[DRY RUN] Would create .gitignore with {gitignore_entry}")
else:
gitignore_path.write_text(f"{gitignore_entry}\n")
gitignore_path.write_text(f"{gitignore_entry}\n", "UTF-8", newline="\n")
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("=" * 60)
print("✅ Setup complete!")
+10 -20
View File
@@ -3,35 +3,24 @@ import argparse
import asyncio
import os
import uvicorn
from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config, Server
from .APP_MODULE import APP_VAR
DEFAULT_PORT = 5080
def run_server(endpoints: list[dict], *, proxy="", devmode=False):
def run_server(endpoints: list[dict], *, proxy=""):
conf: dict[str, object] = {"app": "MODULE_NAME.APP_MODULE:APP_VAR"}
if proxy:
conf["proxy_headers"] = True
conf["forwarded_allow_ips"] = proxy
if devmode:
conf["reload"] = True
conf["reload_dirs"] = ["MODULE_NAME"]
APP_VAR.debug = True
if len(endpoints) > 1:
# Run separate servers for multiple endpoints
async def serve_all():
async with asyncio.TaskGroup() as tg:
for ep in endpoints:
tg.create_task(Server(Config(**conf, **ep)).serve())
async def serve_all():
async with asyncio.TaskGroup() as tg:
for ep in endpoints:
tg.create_task(Server(Config(**conf, **ep)).serve())
asyncio.run(serve_all())
else:
uvicorn.run(**conf, **endpoints[0])
asyncio.run(serve_all())
def main():
@@ -46,9 +35,10 @@ def main():
)
args = parser.parse_args()
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
devmode = bool(os.getenv("FASTAPI_VUE_FRONTEND_URL"))
endpoints = parse_endpoint(args.endpoint, DEFAULT_PORT)
run_server(endpoints, proxy=proxy, devmode=devmode)
try:
run_server(parse_endpoint(args.endpoint, DEFAULT_PORT), proxy=proxy)
except KeyboardInterrupt:
pass
if __name__ == "__main__":
+47 -235
View File
@@ -1,242 +1,64 @@
#!/usr/bin/env -S uv run
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
Usage:
uv run scripts/devserver.py [host:port] [--backend host:port]
The optional host:port argument sets where the Vite frontend listens.
Supported forms: host[:port], :port (all interfaces), or just port.
The --backend option sets where the FastAPI backend listens (default: localhost:5180).
Environment:
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is.
"""
"""Run Vite development server for frontend and FastAPI backend with auto-reload."""
import argparse
import asyncio
import contextlib
import os
import sys
from pathlib import Path
from sys import stderr
import httpx
from fastapi_vue.hostutil import parse_endpoint
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
DEFAULT_VITE_PORT = 5173
DEFAULT_BACKEND_PORT = 5180
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
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
"""
BUN_BUG = """\
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
┃ Consider using deno or npm instead for development.
"""
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from devutil import ( # type: ignore
ProcessGroup,
logger,
ready,
setup_fastapi,
setup_vite,
)
def resolve_frontend_tools(
vite_port: int, all_ifaces: bool
) -> tuple[list[str], list[str], str]:
"""Resolve frontend install and dev commands.
Returns (install_cmd, dev_cmd, tool_name).
Raises SystemExit if tools are not available.
"""
if not (FRONTEND_PATH / "package.json").exists():
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
async def run_devserver(frontend: str, backend: str) -> None:
reporoot = Path(__file__).parent.parent
front = reporoot / "frontend"
if not (front / "package.json").exists():
logger.warning("Frontend source not found at %s", front)
raise SystemExit(1)
result = find_js_runtime() # noqa # type: ignore
if result is None:
if not os.environ.get("JS_RUNTIME"):
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
raise SystemExit(1)
frontend_url, npm_install, vite = setup_vite(frontend)
backend_url, fastapi = setup_fastapi(backend, "MODULE_NAME.APP_MODULE:APP_VAR")
tool, name = result
# Tell the everyone where the frontend and backend are (vite proxy, etc)
os.environ["FASTAPI_VUE_FRONTEND_URL"] = frontend_url
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
install_args = {
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
"npm": ("install", "--silent"),
"bun": ("install", "--silent"),
}
dev_args = {
"deno": ("run", "dev", "--"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}
install_cmd = [tool, *install_args[name]]
dev_cmd = [
tool,
*dev_args[name],
"--clearScreen=false",
f"--port={vite_port}",
]
if all_ifaces:
dev_cmd.append("--host")
if name == "bun":
stderr.write(BUN_BUG)
return install_cmd, dev_cmd, name
async def wait_for_backend(host: str, port: int):
"""Wait for the backend to be ready by polling the health endpoint."""
max_attempts = 50
url = f"http://{host}:{port}"
async with httpx.AsyncClient() as client:
for attempt in range(max_attempts):
try:
await client.get(url, timeout=1.0)
stderr.write("✓ Backend ready!\n")
return True
except httpx.RequestError:
if attempt == max_attempts - 1:
stderr.write("┃ ⚠️ Backend didn't start in time\n")
return False
await asyncio.sleep(0.1)
return False
async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> None:
"""Gracefully terminate a subprocess."""
if proc.returncode is not None:
return
try:
proc.terminate()
except ProcessLookupError:
return
try:
await asyncio.wait_for(proc.wait(), timeout=2)
except TimeoutError:
try:
proc.kill()
except ProcessLookupError:
return
await proc.wait()
async def run_devserver(
vite_port: int,
all_ifaces: bool,
backend_host: str,
backend_port: int,
) -> None:
"""Run the development server with install, backend, and frontend."""
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port, all_ifaces)
# Tell the backend where the Vite dev server is
os.environ["FASTAPI_VUE_FRONTEND_URL"] = f"http://localhost:{vite_port}"
# Tell Vite where the backend is (for proxying /api requests)
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
backend_cmd = [
"uvicorn",
"MODULE_NAME.app:app",
"--host",
backend_host,
"--port",
str(backend_port),
"--reload",
]
cwd = str(Path(__file__).parent.parent)
frontend_cwd = str(FRONTEND_PATH)
backend_proc: asyncio.subprocess.Process | None = None
install_proc: asyncio.subprocess.Process | None = None
frontend_proc: asyncio.subprocess.Process | None = None
try:
# Start install (concurrent with backend)
stderr.write(f">>> {tool_name} {' '.join(install_cmd[1:])}\n")
install_proc = await asyncio.create_subprocess_exec(
*install_cmd, cwd=frontend_cwd
async with ProcessGroup() as pg:
install_proc = await pg.spawn(*npm_install, cwd=front)
await asyncio.sleep(0.2) # reduce message overlap
await pg.spawn(
*fastapi,
"--reload",
"--reload-dir=MODULE_NAME", # Don't reload on frontend changes
"--forwarded-allow-ips=*",
cwd=reporoot,
)
await asyncio.sleep(0.1)
# Wait for both install and backend to be ready
async with asyncio.TaskGroup() as tg:
tg.create_task(pg.wait(install_proc))
tg.create_task(ready(backend_url, path="/api/health?from=devserver.py"))
# Start backend (concurrent with install)
stderr.write(f">>> {' '.join(backend_cmd)}\n")
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
# Wait for install to complete and backend to be ready
install_task = asyncio.create_task(install_proc.wait(), name="install")
backend_ready_task = asyncio.create_task(
wait_for_backend(backend_host, backend_port), name="backend_ready"
)
done, pending = await asyncio.wait(
{install_task, backend_ready_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in done:
if task.get_name() == "install":
if task.result() != 0:
stderr.write("┃ ⚠️ Install failed\n")
raise SystemExit(1)
elif task.get_name() == "backend_ready" and not task.result():
raise SystemExit(1)
if pending:
done2, _ = await asyncio.wait(pending)
for task in done2:
if task.get_name() == "install":
if task.result() != 0:
stderr.write("┃ ⚠️ Install failed\n")
raise SystemExit(1)
elif task.get_name() == "backend_ready" and not task.result():
raise SystemExit(1)
install_proc = None
# Start Vite dev server
stderr.write(f">>> {tool_name} {' '.join(dev_cmd[1:])}\n")
frontend_proc = await asyncio.create_subprocess_exec(*dev_cmd, cwd=frontend_cwd)
# Wait for either process to exit
done, pending = await asyncio.wait(
{
asyncio.create_task(backend_proc.wait(), name="backend"),
asyncio.create_task(frontend_proc.wait(), name="frontend"),
},
return_when=asyncio.FIRST_COMPLETED,
)
for t in done:
t.result()
for t in pending:
t.cancel()
except asyncio.CancelledError:
stderr.write("\n✓ Shutting down...\n")
finally:
if frontend_proc is not None:
await _terminate_process(frontend_proc, "frontend")
if install_proc is not None:
await _terminate_process(install_proc, "install")
if backend_proc is not None:
await _terminate_process(backend_proc, "backend")
# Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
await pg.spawn(*vite, cwd=front)
def main():
parser = argparse.ArgumentParser(
description="Run Vite and FastAPI development servers",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=EPILOG,
epilog=HELP_EPILOG,
)
parser.add_argument(
"frontend",
@@ -250,27 +72,17 @@ def main():
help="FastAPI backend endpoint (default: localhost:5180)",
)
args = parser.parse_args()
# parse_endpoint returns list of dicts with host/port or uds keys
# Multiple entries means bind all interfaces (IPv4 + IPv6)
vite_endpoints = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
backend_endpoints = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
# Vite doesn't support unix sockets
if "uds" in vite_endpoints[0]:
stderr.write("┃ ⚠️ Unix sockets not supported for frontend\n")
raise SystemExit(1)
if "uds" in backend_endpoints[0]:
stderr.write("┃ ⚠️ Unix sockets not supported for backend\n")
raise SystemExit(1)
vite_port = vite_endpoints[0]["port"]
all_ifaces = len(vite_endpoints) > 1
backend_host = backend_endpoints[0]["host"]
backend_port = backend_endpoints[0]["port"]
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(run_devserver(vite_port, all_ifaces, backend_host, backend_port))
asyncio.run(run_devserver(args.frontend, args.backend))
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
JS_RUNTIME environment variable can be used to select the JS runtime
"""
if __name__ == "__main__":
+4 -23
View File
@@ -1,34 +1,15 @@
"""Hatch build hook for building Vue frontend during package build."""
import subprocess
import sys
from pathlib import Path
from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
def run(cmd, **kwargs):
"""Run a command and display it."""
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
stderr.write(f"### {' '.join(display_cmd)}\n")
subprocess.run(cmd, check=True, **kwargs)
sys.path.insert(0, str(Path(__file__).parent))
from buildutil import build
class CustomBuildHook(BuildHookInterface):
"""Build hook that compiles Vue frontend before packaging."""
def initialize(self, version, build_data):
super().initialize(version, build_data)
stderr.write(">>> Building the frontend\n")
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
try:
run(install_cmd, cwd="frontend")
stderr.write("\n")
run(build_cmd, cwd="frontend")
except Exception as e:
stderr.write(f"Error occurred while building frontend: {e}\n")
raise
build("frontend")
-87
View File
@@ -1,87 +0,0 @@
"""Shared utilities for build and dev scripts."""
import os
import shutil
from pathlib import Path
from sys import stderr
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:
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not found\n")
return None
return tool, option
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not recognized\n")
return None
# Auto-detect
for option in options:
if tool := shutil.which(option):
return tool, option
return None
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"),
}
result = find_js_runtime()
if result is None:
raise RuntimeError(
"Deno, npm or Bun is required for building but none was found"
)
tool, name = result
return [tool, *install[name]], [tool, *build[name]]
def find_dev_tool():
"""Find JavaScript runtime and construct dev command.
Returns (dev_cmd, tool_name) or (None, None) if not found.
"""
dev_args = {
"deno": ("run", "dev", "--"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}
result = find_js_runtime()
if result is None:
return None, None
tool, name = result
return [tool, *dev_args[name]], name