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 argparse
import os import os
import re import re
import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@@ -118,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:
@@ -127,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"]
@@ -176,7 +177,9 @@ def find_fastapi_app(
return None 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. """Find FastAPI app by following the CLI entrypoint in pyproject.toml.
If pyproject.toml has a script like `myapp = "myapp.subpkg.__main__:main"`, 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 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
@@ -255,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
@@ -266,7 +269,7 @@ def _find_existing_cli_entrypoint(project_dir: Path, module_name: str) -> str |
# Look for any script that references our module # Look for any script that references our module
for script_name, entry in scripts.items(): for script_name, entry in scripts.items():
if isinstance(entry, str) and entry.startswith(f"{module_name}."): if isinstance(entry, str) and entry.startswith(f"{module_name}."):
return f"{script_name} = \"{entry}\"" return f'{script_name} = "{entry}"'
return None 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 - 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
@@ -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: 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
@@ -348,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:
@@ -415,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:
@@ -450,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:
@@ -505,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
@@ -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") 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:
@@ -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}") 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
@@ -621,11 +624,14 @@ def write_file(
dry_run: bool = False, dry_run: bool = False,
executable: bool = False, executable: bool = False,
fallback_path: Path | None = None, fallback_path: Path | None = None,
force: bool = False,
) -> bool: ) -> bool:
"""Write content to a file, handling existing files and dry-run. """Write content to a file, handling existing files and dry-run.
If fallback_path is provided and the file exists without the upgrade marker, 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. 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() exists = path.exists()
if exists and not overwrite: if exists and not overwrite:
@@ -634,13 +640,13 @@ 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
# If overwrite requested but file doesn't have upgrade marker # If overwrite requested but file doesn't have upgrade marker (unless force)
if overwrite and UPGRADE_MARKER not in existing_content: if overwrite and not force and UPGRADE_MARKER not in existing_content:
if fallback_path is not None: if fallback_path is not None:
# Write to fallback path instead # Write to fallback path instead
return _write_fallback_file( return _write_fallback_file(
@@ -655,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"
@@ -673,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
@@ -683,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)")
@@ -720,6 +726,7 @@ def merge_pyproject(
req = data["project"]["requires-python"] req = data["project"]["requires-python"]
# Parse minimum version from strings like ">=3.10" or ">=3.9,<4" # Parse minimum version from strings like ">=3.10" or ">=3.9,<4"
import re import re
match = re.search(r">=\s*(\d+)\.(\d+)", req) match = re.search(r">=\s*(\d+)\.(\d+)", req)
if match: if match:
major, minor = int(match.group(1)), int(match.group(2)) 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 (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Returns None if no runtime is found. Returns None if no runtime is found.
""" """
import shutil
options = ["deno", "npm", "bun"] options = ["deno", "npm", "bun"]
# Check for JS_RUNTIME environment variable # Check for JS_RUNTIME environment variable
@@ -891,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"
@@ -946,7 +954,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
fastapi_vue_scripts = scripts_dir / "fastapi-vue" fastapi_vue_scripts = scripts_dir / "fastapi-vue"
# Find existing FastAPI app # 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: if app_info:
app_file, app_var = app_info app_file, app_var = app_info
@@ -962,30 +972,37 @@ def cmd_setup(args: argparse.Namespace) -> int:
# Create directories # Create directories
if not dry_run: if not dry_run:
fastapi_vue_scripts.mkdir(parents=True, exist_ok=True)
if not module_dir.exists(): if not module_dir.exists():
module_dir.mkdir(parents=True) module_dir.mkdir(parents=True)
fastapi_vue_scripts.mkdir(parents=True, exist_ok=True)
# === Install scripts (always update our own scripts) === # === 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 # 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: # Remove obsolete util.py if present
template = load_template(template_path) obsolete_util = fastapi_vue_scripts / "util.py"
content = render_template(template, **tpl_vars) if obsolete_util.exists():
write_file( if dry_run:
dest_path, print(f"[DRY RUN] Would remove obsolete {obsolete_util}")
content, else:
overwrite=True, obsolete_util.unlink()
dry_run=dry_run, 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.py - use fallback if customized by user
devserver_path = scripts_dir / "devserver.py" 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) write_file(app_file_path, content, overwrite=False, dry_run=dry_run)
# === Handle __main__.py === # === Handle __main__.py ===
# Skip if project already has a CLI entrypoint in pyproject.toml main_file = module_dir / "__main__.py"
existing_cli = _find_existing_cli_entrypoint(project_dir, module_name) main_fallback = module_dir / "__main__.new.py"
if existing_cli: template = load_template("backend/__main__.py")
print(f"️ Using existing CLI entrypoint: {existing_cli}") main_content = render_template(template, **tpl_vars)
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)
# Use write_file with fallback - it handles all cases: # Check if project already has a CLI entrypoint in pyproject.toml
# - File doesn't exist: create it existing_cli = _find_existing_cli_entrypoint(project_dir, module_name)
# - File exists with marker: update it
# - File exists without marker: write to fallback if main_file.exists():
# File exists: update if it has the auto-upgrade marker, otherwise use fallback
write_file( write_file(
main_file, main_file,
main_content, main_content,
overwrite=True, overwrite=True,
dry_run=dry_run, dry_run=dry_run,
fallback_path=main_fallback, 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 === # === Update vite.config.js/ts ===
frontend_dir = project_dir / "frontend" frontend_dir = project_dir / "frontend"
@@ -1070,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)
@@ -1089,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"
@@ -1125,14 +1126,31 @@ def cmd_setup(args: argparse.Namespace) -> int:
else: else:
nl = b"\r\n" if b"\r\n" in gitignore_content else b"\n" nl = b"\r\n" if b"\r\n" in gitignore_content else b"\n"
suffix = b"" if gitignore_content.endswith(nl) else nl 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") print(f"✅ Added {gitignore_entry} to .gitignore")
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!")
+10 -20
View File
@@ -3,35 +3,24 @@ import argparse
import asyncio import asyncio
import os import os
import uvicorn
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config, Server from uvicorn import Config, Server
from .APP_MODULE import APP_VAR
DEFAULT_PORT = 5080 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"} conf: dict[str, object] = {"app": "MODULE_NAME.APP_MODULE:APP_VAR"}
if proxy: if proxy:
conf["proxy_headers"] = True conf["proxy_headers"] = True
conf["forwarded_allow_ips"] = proxy conf["forwarded_allow_ips"] = proxy
if devmode:
conf["reload"] = True
conf["reload_dirs"] = ["MODULE_NAME"]
APP_VAR.debug = True
if len(endpoints) > 1: async def serve_all():
# Run separate servers for multiple endpoints async with asyncio.TaskGroup() as tg:
async def serve_all(): for ep in endpoints:
async with asyncio.TaskGroup() as tg: tg.create_task(Server(Config(**conf, **ep)).serve())
for ep in endpoints:
tg.create_task(Server(Config(**conf, **ep)).serve())
asyncio.run(serve_all()) asyncio.run(serve_all())
else:
uvicorn.run(**conf, **endpoints[0])
def main(): def main():
@@ -46,9 +35,10 @@ def main():
) )
args = parser.parse_args() args = parser.parse_args()
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1") proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
devmode = bool(os.getenv("FASTAPI_VUE_FRONTEND_URL")) try:
endpoints = parse_endpoint(args.endpoint, DEFAULT_PORT) run_server(parse_endpoint(args.endpoint, DEFAULT_PORT), proxy=proxy)
run_server(endpoints, proxy=proxy, devmode=devmode) except KeyboardInterrupt:
pass
if __name__ == "__main__": if __name__ == "__main__":
+47 -235
View File
@@ -1,242 +1,64 @@
#!/usr/bin/env -S uv run #!/usr/bin/env -S uv run
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file # auto-upgrade@fastapi-vue-setup - remove this if you modify this file
"""Run Vite development server for frontend and FastAPI backend with auto-reload. """Run Vite development server for 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.
"""
import argparse import argparse
import asyncio import asyncio
import contextlib import contextlib
import os import os
import sys
from pathlib import Path from pathlib import Path
from sys import stderr
import httpx # Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
from fastapi_vue.hostutil import parse_endpoint sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from devutil import ( # type: ignore
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102 ProcessGroup,
logger,
DEFAULT_VITE_PORT = 5173 ready,
DEFAULT_BACKEND_PORT = 5180 setup_fastapi,
FRONTEND_PATH = Path(__file__).parent.parent / "frontend" setup_vite,
)
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.
"""
def resolve_frontend_tools( async def run_devserver(frontend: str, backend: str) -> None:
vite_port: int, all_ifaces: bool reporoot = Path(__file__).parent.parent
) -> tuple[list[str], list[str], str]: front = reporoot / "frontend"
"""Resolve frontend install and dev commands. if not (front / "package.json").exists():
logger.warning("Frontend source not found at %s", front)
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")
raise SystemExit(1) raise SystemExit(1)
result = find_js_runtime() # noqa # type: ignore frontend_url, npm_install, vite = setup_vite(frontend)
if result is None: backend_url, fastapi = setup_fastapi(backend, "MODULE_NAME.APP_MODULE:APP_VAR")
if not os.environ.get("JS_RUNTIME"):
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
raise SystemExit(1)
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 = { async with ProcessGroup() as pg:
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"), install_proc = await pg.spawn(*npm_install, cwd=front)
"npm": ("install", "--silent"), await asyncio.sleep(0.2) # reduce message overlap
"bun": ("install", "--silent"), await pg.spawn(
} *fastapi,
dev_args = { "--reload",
"deno": ("run", "dev", "--"), "--reload-dir=MODULE_NAME", # Don't reload on frontend changes
"npm": ("--silent", "run", "dev", "--"), "--forwarded-allow-ips=*",
"bun": ("run", "dev", "--"), cwd=reporoot,
}
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
) )
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) # Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
stderr.write(f">>> {' '.join(backend_cmd)}\n") await pg.spawn(*vite, cwd=front)
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")
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Run Vite and FastAPI development servers", description="Run Vite and FastAPI development servers",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=EPILOG, epilog=HELP_EPILOG,
) )
parser.add_argument( parser.add_argument(
"frontend", "frontend",
@@ -250,27 +72,17 @@ def main():
help="FastAPI backend endpoint (default: localhost:5180)", help="FastAPI backend endpoint (default: localhost:5180)",
) )
args = parser.parse_args() 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): 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__": if __name__ == "__main__":
+4 -23
View File
@@ -1,34 +1,15 @@
"""Hatch build hook for building Vue frontend during package build.""" """Hatch build hook for building Vue frontend during package build."""
import subprocess import sys
from pathlib import Path from pathlib import Path
from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102 sys.path.insert(0, str(Path(__file__).parent))
from buildutil import build
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)
class CustomBuildHook(BuildHookInterface): class CustomBuildHook(BuildHookInterface):
"""Build hook that compiles Vue frontend before packaging."""
def initialize(self, version, build_data): def initialize(self, version, build_data):
super().initialize(version, build_data) super().initialize(version, build_data)
stderr.write(">>> Building the frontend\n") build("frontend")
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
-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