Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05fd5c562e | ||
|
|
5f0e74713f | ||
|
|
2d117a9d5e | ||
|
|
4bf102579a | ||
|
|
fbed7873e5 | ||
|
|
008b3593db | ||
|
|
3820c6f3c1 | ||
|
|
c300a1a291 | ||
|
|
bdfaa22b0c | ||
|
|
47e5ec279c | ||
|
|
093b83737e |
@@ -1,8 +1,10 @@
|
|||||||
"""FastAPI static file serving with zstd compression and SPA support."""
|
"""FastAPI static file serving with zstd compression and SPA support."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from functools import partial
|
from functools import partial
|
||||||
@@ -17,29 +19,44 @@ from starlette.exceptions import HTTPException
|
|||||||
from starlette.routing import Route
|
from starlette.routing import Route
|
||||||
from zstandard import ZstdCompressor
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
# Dev mode: index files but don't load content, return error responses
|
|
||||||
_DEVMODE = os.getenv("FASTAPI_VUE_FRONTEND_URL")
|
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error") # Use FastAPI logging style
|
logger = logging.getLogger("uvicorn.error") # Use FastAPI logging style
|
||||||
|
|
||||||
|
__all__ = ["Frontend"]
|
||||||
|
|
||||||
|
|
||||||
|
class Assets:
|
||||||
|
"""Default cached value to /assets/"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(cached: str | list[str] | Assets) -> list[str]:
|
||||||
|
match cached:
|
||||||
|
case Assets():
|
||||||
|
return ["/assets/"]
|
||||||
|
case str():
|
||||||
|
return [cached]
|
||||||
|
case list():
|
||||||
|
return cached
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"Invalid cached value: {cached!r}")
|
||||||
|
|
||||||
|
|
||||||
class Frontend:
|
class Frontend:
|
||||||
"""Static file server with automatic zstd compression and caching.
|
"""Static file server with automatic zstd compression and caching.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Automatic zstd compression for compressible files
|
- Automatic zstd compression for compressible files
|
||||||
- ETag-based caching with configurable cache headers
|
- ETag-based caching of immutable assets
|
||||||
- SPA (Single Page Application) support
|
- SPA (Single Page Application) support
|
||||||
- Favicon handling from hashed assets
|
- /favicon.ico with correct MIME type (image/png etc)
|
||||||
- Dev mode: indexes files but returns error directing to Vite server
|
- Dev mode: indexes files but returns error directing to Vite server
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
directory: Path to the directory containing static files
|
directory: Path to the directory containing static files
|
||||||
index: Name of the index file (default: "index.html")
|
index: Name of the index file (default: "index.html")
|
||||||
spa: Enable SPA mode - serve index.html for unknown routes (default: False)
|
spa: Enable SPA mode - serve index.html for unknown routes (default: False)
|
||||||
cached: Path prefixes that should have immutable cache headers
|
cached: Path prefixes that are immutable (default: "/assets/")
|
||||||
|
favicon: May use wildcards of full path. E.g. /assets/logo*.png matches logo.hash.png created by Vite
|
||||||
zstdlevel: Zstd compression level (default: 18)
|
zstdlevel: Zstd compression level (default: 18)
|
||||||
favicon: Path to favicon for automatic /favicon.ico handling
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -49,24 +66,18 @@ class Frontend:
|
|||||||
index: str = "index.html",
|
index: str = "index.html",
|
||||||
spa: bool = False,
|
spa: bool = False,
|
||||||
catch_all: bool | None = None,
|
catch_all: bool | None = None,
|
||||||
cached: str | list[str] | None = None,
|
cached: str | list[str] | Assets = Assets(),
|
||||||
zstdlevel: int = 18,
|
|
||||||
favicon: str | None = None,
|
favicon: str | None = None,
|
||||||
|
zstdlevel: int = 18,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
self.www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
||||||
self.base: Path = Path(directory)
|
self.base: Path = Path(directory)
|
||||||
self.index = index
|
self.index = index
|
||||||
self.spa = spa
|
self.spa = spa
|
||||||
self._catch_all = spa if catch_all is None else catch_all
|
self._catch_all = spa if catch_all is None else catch_all
|
||||||
if cached is None:
|
self.cached_paths = Assets.parse(cached)
|
||||||
self.cached_paths = []
|
|
||||||
elif isinstance(cached, str):
|
|
||||||
self.cached_paths = [cached]
|
|
||||||
else:
|
|
||||||
self.cached_paths = cached
|
|
||||||
self.zstdlevel = zstdlevel
|
self.zstdlevel = zstdlevel
|
||||||
self.favicon = favicon
|
self.favicon = favicon
|
||||||
self.devmode = bool(_DEVMODE)
|
|
||||||
self._app: FastAPI | None = None
|
self._app: FastAPI | None = None
|
||||||
self._mount_path: str = ""
|
self._mount_path: str = ""
|
||||||
self._ridx: int = 0
|
self._ridx: int = 0
|
||||||
@@ -132,15 +143,11 @@ class Frontend:
|
|||||||
zstd = None
|
zstd = None
|
||||||
www[name] = data, zstd, headers
|
www[name] = data, zstd, headers
|
||||||
if self.favicon:
|
if self.favicon:
|
||||||
p = PurePosixPath(self.favicon)
|
if m := fnmatch.filter(www, self.favicon):
|
||||||
base = str(p.with_suffix(""))
|
data, zstd, headers = www[m[0]]
|
||||||
ext = p.suffix
|
if "immutable" in headers.get("cache-control", ""):
|
||||||
hashed_path = next(
|
headers = {**headers, "cache-control": "max-age=86400"}
|
||||||
(path for path in www if path.startswith(base) and path.endswith(ext)),
|
www["/favicon.ico"] = data, zstd, headers
|
||||||
None,
|
|
||||||
)
|
|
||||||
if hashed_path:
|
|
||||||
www["/favicon.ico"] = www[hashed_path]
|
|
||||||
if not www:
|
if not www:
|
||||||
msg = "Frontend files missing, check your installation.\n"
|
msg = "Frontend files missing, check your installation.\n"
|
||||||
www["/"] = (
|
www["/"] = (
|
||||||
@@ -154,12 +161,15 @@ class Frontend:
|
|||||||
)
|
)
|
||||||
return www
|
return www
|
||||||
|
|
||||||
async def load(self, *, log=True):
|
async def load(self, *, debug: bool | None = None, log: bool = True):
|
||||||
"""Load or reload static files from disk.
|
"""Load or reload static files from disk.
|
||||||
|
|
||||||
In dev mode (FASTAPI_VUE_FRONTEND_URL set), only indexes paths without loading content.
|
In debug mode, returns 409 instead of files (avoid accidental use of stale builds)
|
||||||
|
If debug is None, uses app.debug (app passed to frontend.route)
|
||||||
"""
|
"""
|
||||||
if self.devmode:
|
if debug is None:
|
||||||
|
debug = getattr(self._app, "debug", False)
|
||||||
|
if debug:
|
||||||
# Dev mode: just index paths, no content loading
|
# Dev mode: just index paths, no content loading
|
||||||
self._devmode_paths = await run_in_threadpool(self._index_only)
|
self._devmode_paths = await run_in_threadpool(self._index_only)
|
||||||
self._register_routes()
|
self._register_routes()
|
||||||
@@ -180,6 +190,8 @@ class Frontend:
|
|||||||
f"{self.base.name}: {len(self.www)} files in {1000 * duration:.1f} ms | "
|
f"{self.base.name}: {len(self.www)} files in {1000 * duration:.1f} ms | "
|
||||||
f"zstd {len(compfiles)} files {1e-6 * raw:.2f}->{1e-6 * comp:.2f} MB ({ratio:.0f} %)"
|
f"zstd {len(compfiles)} files {1e-6 * raw:.2f}->{1e-6 * comp:.2f} MB ({ratio:.0f} %)"
|
||||||
)
|
)
|
||||||
|
if self.favicon and "/favicon.ico" not in self.www:
|
||||||
|
logger.warning("Favicon not found: %s", self.favicon)
|
||||||
|
|
||||||
def route(self, app: FastAPI, mount_path="/"):
|
def route(self, app: FastAPI, mount_path="/"):
|
||||||
"""Register frontend routes with a FastAPI app.
|
"""Register frontend routes with a FastAPI app.
|
||||||
@@ -214,13 +226,14 @@ class Frontend:
|
|||||||
self._routes.clear()
|
self._routes.clear()
|
||||||
|
|
||||||
# Get paths and select handler based on mode (checked once, not per request)
|
# Get paths and select handler based on mode (checked once, not per request)
|
||||||
paths = self._devmode_paths if self.devmode else self.www.keys()
|
debug = getattr(self._app, "debug", False)
|
||||||
handler = _devmode_respond if self.devmode else self._respond
|
paths = self._devmode_paths if debug else self.www.keys()
|
||||||
|
handler = _devmode_respond if debug else self._respond
|
||||||
# Insert at the position where route() was called
|
# Insert at the position where route() was called
|
||||||
self._app.routes[self._ridx : self._ridx] = self._routes = [
|
self._app.routes[self._ridx : self._ridx] = self._routes = [
|
||||||
Route(
|
Route(
|
||||||
self._mount_path + p,
|
self._mount_path + p,
|
||||||
endpoint=handler if self.devmode else partial(handler, name=p),
|
endpoint=handler if debug else partial(handler, name=p),
|
||||||
methods=["GET", "HEAD"],
|
methods=["GET", "HEAD"],
|
||||||
name=f"frontend{p.replace('/', '_')}",
|
name=f"frontend{p.replace('/', '_')}",
|
||||||
)
|
)
|
||||||
@@ -241,7 +254,8 @@ class Frontend:
|
|||||||
def handle(self, request: Request, path: str):
|
def handle(self, request: Request, path: str):
|
||||||
"""SPA catch-all handler with directory redirects and fallback to index."""
|
"""SPA catch-all handler with directory redirects and fallback to index."""
|
||||||
name = path.removesuffix(self.index)
|
name = path.removesuffix(self.index)
|
||||||
files = self._devmode_paths if self.devmode else self.www
|
debug = getattr(self._app, "debug", False)
|
||||||
|
files = self._devmode_paths if debug else self.www
|
||||||
|
|
||||||
if name not in files:
|
if name not in files:
|
||||||
# Friendly redirect for directories missing trailing slash
|
# Friendly redirect for directories missing trailing slash
|
||||||
@@ -254,7 +268,7 @@ class Frontend:
|
|||||||
if name not in files:
|
if name not in files:
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
return (_devmode_respond if self.devmode else self._respond)(request, name)
|
return (_devmode_respond if debug else self._respond)(request, name)
|
||||||
|
|
||||||
|
|
||||||
def _devmode_respond(request: Request, name=""):
|
def _devmode_respond(request: Request, name=""):
|
||||||
@@ -262,6 +276,6 @@ def _devmode_respond(request: Request, name=""):
|
|||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
content={
|
content={
|
||||||
"detail": f"Frontend assets served by Vite in dev mode. Connect via {_DEVMODE} instead."
|
"detail": "Frontend assets served by Vite in debug mode. You are on backend, connect to frontend instead."
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+153
-70
@@ -12,6 +12,7 @@ Options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import ast
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -22,9 +23,46 @@ from textwrap import indent
|
|||||||
|
|
||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
|
# Track Python files written/patched for ruff formatting
|
||||||
|
_python_files_to_format: list[Path] = []
|
||||||
|
|
||||||
# Template directory
|
# Template directory
|
||||||
TEMPLATE_DIR = Path(__file__).parent / "template"
|
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"🔧 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
|
||||||
|
) -> None:
|
||||||
|
"""Add packages using uv."""
|
||||||
|
cmd = ["uv", "add", "-q", "-U"]
|
||||||
|
if group:
|
||||||
|
cmd.extend(["--group", group])
|
||||||
|
else:
|
||||||
|
cmd.append("--no-sync")
|
||||||
|
cmd.extend(packages)
|
||||||
|
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)
|
# Default ports: (default, vite, dev)
|
||||||
# If vite == dev, dev is incremented by 100
|
# If vite == dev, dev is incremented by 100
|
||||||
DEFAULT_PORTS = (3100, 3100, 3200)
|
DEFAULT_PORTS = (3100, 3100, 3200)
|
||||||
@@ -153,6 +191,24 @@ def parse_ports(ports_str: str | None) -> tuple[int, int, int]:
|
|||||||
return default, vite, dev
|
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:
|
def extract_existing_ports(project_dir: Path) -> tuple[int, int, int] | None:
|
||||||
"""Extract existing port configuration from project files.
|
"""Extract existing port configuration from project files.
|
||||||
|
|
||||||
@@ -421,7 +477,7 @@ def patch_app_file(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Patch an existing app.py with frontend integration.
|
"""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().
|
and tries to patch lifespan with frontend.load().
|
||||||
|
|
||||||
Returns True if patched, False if already patched or failed.
|
Returns True if patched, False if already patched or failed.
|
||||||
@@ -431,60 +487,92 @@ def patch_app_file(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
original_content = path.read_text("UTF-8")
|
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)")
|
print(f"✔️ {path} (already patched)")
|
||||||
return False
|
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}, "/")'
|
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
|
last_import_idx = 0
|
||||||
has_pathlib = False
|
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped.startswith("import ") or stripped.startswith("from "):
|
if stripped.startswith("import ") or stripped.startswith("from "):
|
||||||
last_import_idx = i
|
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:
|
elif stripped and not stripped.startswith("#") and last_import_idx > 0:
|
||||||
# Stop at first non-import, non-comment, non-empty line after imports
|
|
||||||
break
|
break
|
||||||
|
lines.insert(last_import_idx + 1, FRONTEND_BLOCK)
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Append route at end
|
# Append route at end
|
||||||
lines.append("")
|
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)
|
lines.append(route_line)
|
||||||
content = "\n".join(lines)
|
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
|
# 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
|
# Look for yield inside an async def lifespan function
|
||||||
# Find the yield statement and insert before it
|
# Find the yield statement and insert before it
|
||||||
|
if not lifespan_patched:
|
||||||
yield_pattern = r"^([ \t]+)(yield\b)"
|
yield_pattern = r"^([ \t]+)(yield\b)"
|
||||||
yield_match = re.search(yield_pattern, content, re.MULTILINE)
|
yield_match = re.search(yield_pattern, content, re.MULTILINE)
|
||||||
if yield_match:
|
if yield_match:
|
||||||
indent = yield_match.group(1)
|
ws = yield_match.group(1)
|
||||||
insert_pos = yield_match.start()
|
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:]
|
content = content[:insert_pos] + load_code + content[insert_pos:]
|
||||||
lifespan_patched = True
|
lifespan_patched = True
|
||||||
|
|
||||||
@@ -494,10 +582,11 @@ def patch_app_file(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"[DRY RUN] Would patch {path}")
|
print(f"✅ Would patch {path}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
path.write_text(content, "UTF-8", newline="\n")
|
path.write_text(content, "UTF-8", newline="\n")
|
||||||
|
_python_files_to_format.append(path)
|
||||||
print(f"✅ Patched {path}")
|
print(f"✅ Patched {path}")
|
||||||
|
|
||||||
if not lifespan_patched:
|
if not lifespan_patched:
|
||||||
@@ -580,11 +669,11 @@ def patch_vite_config(
|
|||||||
|
|
||||||
# Check if content actually changed
|
# Check if content actually changed
|
||||||
if content == original_content:
|
if content == original_content:
|
||||||
print(f"⚠️ Skipping {path} (no changes needed)")
|
print(f"ℹ️ Skipping {path} (no changes needed)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"[DRY RUN] Would patch {path}")
|
print(f"✅ Would patch {path}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
path.write_text(content, "UTF-8", newline="\n")
|
path.write_text(content, "UTF-8", newline="\n")
|
||||||
@@ -618,7 +707,7 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
|
|||||||
target_file = app_vue
|
target_file = app_vue
|
||||||
|
|
||||||
if target_file is None:
|
if target_file is None:
|
||||||
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("UTF-8")
|
original_content = target_file.read_text("UTF-8")
|
||||||
@@ -663,7 +752,7 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
|
|||||||
else:
|
else:
|
||||||
# Minimal App.vue - only patch if it contains the default welcome message
|
# Minimal App.vue - only patch if it contains the default welcome message
|
||||||
if "<h1>You did it!</h1>" not in content:
|
if "<h1>You did it!</h1>" not in content:
|
||||||
print(f"⚠️ Skipping {target_file} (not a default Vue template)")
|
print(f"ℹ️ Skipping {target_file} (not a default Vue template)")
|
||||||
return False
|
return False
|
||||||
# Insert before the </p> tag
|
# Insert before the </p> tag
|
||||||
template_end = content.find("</template>")
|
template_end = content.find("</template>")
|
||||||
@@ -680,11 +769,11 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
|
|||||||
|
|
||||||
# Check if content actually changed
|
# Check if content actually changed
|
||||||
if content == original_content:
|
if content == original_content:
|
||||||
print(f"⚠️ Skipping {target_file} (no changes needed)")
|
print(f"ℹ️ Skipping {target_file} (no changes needed)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"[DRY RUN] Would patch {target_file}")
|
print(f"✅ Would patch {target_file}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
target_file.write_text(content, "UTF-8", newline="\n")
|
target_file.write_text(content, "UTF-8", newline="\n")
|
||||||
@@ -714,7 +803,7 @@ def write_file(
|
|||||||
"""
|
"""
|
||||||
exists = path.exists()
|
exists = path.exists()
|
||||||
if exists and not overwrite:
|
if exists and not overwrite:
|
||||||
print(f"ℹ️ Skipping {path} (exists)")
|
print(f"⚠️ Skipping {path} (exists)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if content is the same
|
# Check if content is the same
|
||||||
@@ -736,13 +825,15 @@ def write_file(
|
|||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
action = "overwrite" if exists else "create"
|
action = "overwrite" if exists else "create"
|
||||||
print(f"[DRY RUN] Would {action} {path}")
|
print(f"✅ Would {action} {path}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(content, "UTF-8", newline="\n")
|
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)
|
||||||
|
if path.suffix == ".py":
|
||||||
|
_python_files_to_format.append(path)
|
||||||
action = "Updated" if exists else "Created"
|
action = "Updated" if exists else "Created"
|
||||||
print(f"✅ {action} {path}")
|
print(f"✅ {action} {path}")
|
||||||
return True
|
return True
|
||||||
@@ -763,7 +854,7 @@ def _write_fallback_file(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"[DRY RUN] Would create {fallback_path} (original customized by user)")
|
print(f"✅ Would create {fallback_path} (original customized by user)")
|
||||||
_new_files_written.append((fallback_path, original_path))
|
_new_files_written.append((fallback_path, original_path))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -771,6 +862,8 @@ def _write_fallback_file(
|
|||||||
fallback_path.write_text(content, "UTF-8", newline="\n")
|
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)
|
||||||
|
if fallback_path.suffix == ".py":
|
||||||
|
_python_files_to_format.append(fallback_path)
|
||||||
print(f"✅ Created {fallback_path} (original customized by user)")
|
print(f"✅ Created {fallback_path} (original customized by user)")
|
||||||
_new_files_written.append((fallback_path, original_path))
|
_new_files_written.append((fallback_path, original_path))
|
||||||
return True
|
return True
|
||||||
@@ -904,7 +997,7 @@ def ensure_python_project(project_dir: Path, dry_run: bool = False) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"[DRY RUN] Would run: uv init {project_dir}")
|
print(f"📦 Would run: uv init {project_dir}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
print("📦 No pyproject.toml found, initializing Python project...")
|
print("📦 No pyproject.toml found, initializing Python project...")
|
||||||
@@ -948,7 +1041,7 @@ def ensure_frontend(project_dir: Path, dry_run: bool = False) -> bool:
|
|||||||
create_cmd = create_vue_commands[js_name]
|
create_cmd = create_vue_commands[js_name]
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"[DRY RUN] Would run: {' '.join(create_cmd)}")
|
print(f"🎨 Would run: {' '.join(create_cmd)}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
print("🎨 No frontend/ found, creating Vue project...")
|
print("🎨 No frontend/ found, creating Vue project...")
|
||||||
@@ -992,7 +1085,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
# Create project directory if it doesn't exist
|
# Create project directory if it doesn't exist
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"[DRY RUN] Would create directory: {project_dir}")
|
print(f"✅ Would create directory: {project_dir}")
|
||||||
else:
|
else:
|
||||||
project_dir.mkdir(parents=True)
|
project_dir.mkdir(parents=True)
|
||||||
print(f"✅ Created {project_dir}")
|
print(f"✅ Created {project_dir}")
|
||||||
@@ -1015,11 +1108,8 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
if not module_name:
|
if not module_name:
|
||||||
# Derive from directory name
|
# Derive from directory name
|
||||||
module_name = project_dir.name.replace("-", "_")
|
module_name = project_dir.name.replace("-", "_")
|
||||||
print(f"📦 Using module name from directory: {module_name}")
|
print(f"📦 Module: {module_name} (from directory name)")
|
||||||
|
else:
|
||||||
# Title for templates
|
|
||||||
project_title = module_name.replace("_", " ").title()
|
|
||||||
|
|
||||||
print(f"📦 Module: {module_name}")
|
print(f"📦 Module: {module_name}")
|
||||||
|
|
||||||
# Determine port configuration
|
# Determine port configuration
|
||||||
@@ -1040,6 +1130,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
f"📡 Ports: default={default_port}, vite={vite_port}, dev={dev_port} {ports_note}"
|
f"📡 Ports: default={default_port}, vite={vite_port}, dev={dev_port} {ports_note}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Title for templates
|
||||||
|
project_title = module_name.replace("_", " ").title()
|
||||||
|
|
||||||
# Template variables
|
# Template variables
|
||||||
tpl_vars = {
|
tpl_vars = {
|
||||||
"MODULE_NAME": module_name,
|
"MODULE_NAME": module_name,
|
||||||
@@ -1047,6 +1140,8 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
"TEMPLATE_DEFAULT_PORT": str(default_port),
|
"TEMPLATE_DEFAULT_PORT": str(default_port),
|
||||||
"TEMPLATE_VITE_PORT": str(vite_port),
|
"TEMPLATE_VITE_PORT": str(vite_port),
|
||||||
"TEMPLATE_DEV_PORT": str(dev_port),
|
"TEMPLATE_DEV_PORT": str(dev_port),
|
||||||
|
"ENVPREFIX": module_name.upper(),
|
||||||
|
"PROJECT_CLI": module_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
module_dir = project_dir / module_name
|
module_dir = project_dir / module_name
|
||||||
@@ -1084,7 +1179,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
obsolete_util = fastapi_vue_scripts / "util.py"
|
obsolete_util = fastapi_vue_scripts / "util.py"
|
||||||
if obsolete_util.exists():
|
if obsolete_util.exists():
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"[DRY RUN] Would remove obsolete {obsolete_util}")
|
print(f"🗑️ Would remove obsolete {obsolete_util}")
|
||||||
else:
|
else:
|
||||||
obsolete_util.unlink()
|
obsolete_util.unlink()
|
||||||
print(f"🗑️ Removed obsolete {obsolete_util}")
|
print(f"🗑️ Removed obsolete {obsolete_util}")
|
||||||
@@ -1163,7 +1258,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
overwrite=False,
|
overwrite=False,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
)
|
)
|
||||||
# else: no file but has existing entrypoint - don't create (user has custom CLI setup)
|
else:
|
||||||
|
# No file but has existing entrypoint - don't create (user has custom CLI setup)
|
||||||
|
print(f"ℹ️ Skipping __main__.py (package already has CLI: {existing_cli})")
|
||||||
|
|
||||||
# === Update vite.config.js/ts ===
|
# === Update vite.config.js/ts ===
|
||||||
frontend_dir = project_dir / "frontend"
|
frontend_dir = project_dir / "frontend"
|
||||||
@@ -1209,7 +1306,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
if new_content == old_content:
|
if new_content == old_content:
|
||||||
print(f"✔️ {pyproject_path} (already up to date)")
|
print(f"✔️ {pyproject_path} (already up to date)")
|
||||||
elif dry_run:
|
elif dry_run:
|
||||||
print(f"[DRY RUN] Would update {pyproject_path}")
|
print(f"✅ Would update {pyproject_path}")
|
||||||
else:
|
else:
|
||||||
pyproject_path.write_text(new_content, "UTF-8", newline="\n")
|
pyproject_path.write_text(new_content, "UTF-8", newline="\n")
|
||||||
print(f"✅ Updated {pyproject_path}")
|
print(f"✅ Updated {pyproject_path}")
|
||||||
@@ -1222,7 +1319,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
if b"frontend-build" in gitignore_content:
|
if b"frontend-build" in gitignore_content:
|
||||||
print("✔️ .gitignore (frontend-build already ignored)")
|
print("✔️ .gitignore (frontend-build already ignored)")
|
||||||
elif dry_run:
|
elif dry_run:
|
||||||
print(f"[DRY RUN] Would add {gitignore_entry} to .gitignore")
|
print(f"✅ Would add {gitignore_entry} to .gitignore")
|
||||||
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
|
||||||
@@ -1231,33 +1328,19 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
)
|
)
|
||||||
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"✅ Would create .gitignore with {gitignore_entry}")
|
||||||
else:
|
else:
|
||||||
gitignore_path.write_text(f"{gitignore_entry}\n", "UTF-8", newline="\n")
|
gitignore_path.write_text(f"{gitignore_entry}\n", "UTF-8", newline="\n")
|
||||||
print("✅ Created .gitignore")
|
print("✅ Created .gitignore")
|
||||||
|
|
||||||
# === Add dependencies using uv ===
|
# === Add dependencies using uv ===
|
||||||
uv_add_main = [
|
ruff_sort_imports(_python_files_to_format, dry_run=dry_run)
|
||||||
"uv",
|
|
||||||
"add",
|
|
||||||
"-q",
|
|
||||||
"-U",
|
|
||||||
"--no-sync",
|
|
||||||
"fastapi[standard]",
|
|
||||||
"fastapi-vue",
|
|
||||||
]
|
|
||||||
uv_add_dev = ["uv", "add", "-q", "-U", "--group", "dev", "httpx"]
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"[DRY RUN] Would run: {' '.join(uv_add_main)}")
|
print("📦 Would add: fastapi[standard], fastapi-vue, httpx (dev only)")
|
||||||
print(f"[DRY RUN] Would run: {' '.join(uv_add_dev)}")
|
|
||||||
else:
|
else:
|
||||||
print("📦 Adding dependencies...")
|
print("📦 Dependencies")
|
||||||
result = subprocess.run(uv_add_main, cwd=project_dir, check=False)
|
uv_add_packages(["fastapi[standard]", "fastapi-vue"], cwd=project_dir)
|
||||||
if result.returncode != 0:
|
uv_add_packages(["httpx"], cwd=project_dir, group="dev")
|
||||||
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)
|
||||||
|
|||||||
+5
-1
@@ -9,6 +9,7 @@ description = "Tool to create or patch FastAPI+Vue projects with integrated buil
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ruff>=0.14.13",
|
||||||
"tomlkit>=0.12.0",
|
"tomlkit>=0.12.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -26,4 +27,7 @@ source = "vcs"
|
|||||||
include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"]
|
include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["ruff"]
|
dev = ["ruff", "fastapi-vue"]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
fastapi-vue = { path = "fastapi-vue", editable = true }
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
|
|
||||||
from fastapi_vue import server
|
from fastapi_vue import server
|
||||||
|
|
||||||
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
|
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
|
||||||
|
DEVMODE = bool(os.getenv("ENVPREFIX_FRONTEND_URL"))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -19,6 +21,7 @@ def main():
|
|||||||
"MODULE_NAME.APP_MODULE:APP_VAR",
|
"MODULE_NAME.APP_MODULE:APP_VAR",
|
||||||
listen=args.listen,
|
listen=args.listen,
|
||||||
default_port=DEFAULT_PORT,
|
default_port=DEFAULT_PORT,
|
||||||
|
reload=DEVMODE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi_vue import Frontend
|
from fastapi_vue import Frontend
|
||||||
|
from MODULE_NAME.__main__ import DEVMODE
|
||||||
|
|
||||||
# Vue Frontend static files
|
# Vue Frontend static files
|
||||||
frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"])
|
frontend = Frontend(Path(__file__).with_name("frontend-build"))
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -15,7 +16,7 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="PROJECT_TITLE", lifespan=lifespan)
|
app = FastAPI(title="PROJECT_TITLE", debug=DEVMODE, lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
# Add API routes here...
|
# Add API routes here...
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* FastAPI-Vue Vite Plugin
|
* FastAPI-Vue Vite Plugin
|
||||||
|
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
|
||||||
*
|
*
|
||||||
* Configures Vite for FastAPI backend integration:
|
* Configures Vite for FastAPI backend integration:
|
||||||
* - Proxies /api/* requests to the FastAPI backend
|
* - Proxies /api/* requests to the FastAPI backend
|
||||||
* - Builds to the Python module's frontend-build directory
|
* - Builds to the Python module's frontend-build directory
|
||||||
*
|
*
|
||||||
* Environment variables (with defaults):
|
* Options:
|
||||||
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
|
* 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"] } = {}) {
|
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||||
|
const backendUrl = process.env.ENVPREFIX_BACKEND_URL || "http://localhost:TEMPLATE_DEFAULT_PORT"
|
||||||
|
|
||||||
// Build proxy configuration for each path
|
// Build proxy configuration for each path
|
||||||
const proxy = {}
|
const proxy = {}
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
@@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "fastapi-vite",
|
name: "vite-plugin-fastapi-MODULE_NAME",
|
||||||
config: () => ({
|
config: () => ({
|
||||||
server: { proxy },
|
server: { proxy },
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -4,13 +4,11 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi_vue import server
|
|
||||||
|
|
||||||
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
# 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")))
|
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||||
from devutil import ( # type: ignore
|
from devutil import ( # type: ignore
|
||||||
@@ -18,7 +16,7 @@ from devutil import ( # type: ignore
|
|||||||
check_ports_free,
|
check_ports_free,
|
||||||
logger,
|
logger,
|
||||||
ready,
|
ready,
|
||||||
setup_fastapi,
|
setup_cli,
|
||||||
setup_vite,
|
setup_vite,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,7 +24,9 @@ DEFAULT_VITE_PORT = TEMPLATE_VITE_PORT
|
|||||||
DEFAULT_DEV_PORT = TEMPLATE_DEV_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
|
reporoot = Path(__file__).parent.parent
|
||||||
front = reporoot / "frontend"
|
front = reporoot / "frontend"
|
||||||
if not (front / "package.json").exists():
|
if not (front / "package.json").exists():
|
||||||
@@ -34,26 +34,17 @@ async def run_devserver(frontend: str, backend: str) -> None:
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
viteurl, npm_install, vite = setup_vite(frontend, DEFAULT_VITE_PORT)
|
viteurl, npm_install, vite = setup_vite(frontend, DEFAULT_VITE_PORT)
|
||||||
backurl, module, backend_config = setup_fastapi(
|
backurl, MODULE_NAME = setup_cli("PROJECT_CLI", backend, DEFAULT_DEV_PORT)
|
||||||
backend, "MODULE_NAME.APP_MODULE:APP_VAR", DEFAULT_DEV_PORT
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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"] = viteurl
|
os.environ["ENVPREFIX_FRONTEND_URL"] = viteurl
|
||||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = backurl
|
os.environ["ENVPREFIX_BACKEND_URL"] = backurl
|
||||||
|
|
||||||
async with ProcessGroup() as pg:
|
async with ProcessGroup() as pg:
|
||||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||||
await check_ports_free(viteurl, backurl)
|
await check_ports_free(viteurl, backurl)
|
||||||
|
await pg.spawn(*MODULE_NAME, *(extra_args or []))
|
||||||
# Run backend in a thread (server.run is blocking)
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
loop.run_in_executor(None, lambda: server.run(module, **backend_config))
|
|
||||||
|
|
||||||
# Wait for both install and backend to be ready
|
|
||||||
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
|
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
|
||||||
|
|
||||||
# Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
|
|
||||||
await pg.spawn(*vite, cwd=front)
|
await pg.spawn(*vite, cwd=front)
|
||||||
|
|
||||||
|
|
||||||
@@ -74,17 +65,16 @@ def main():
|
|||||||
metavar="host:port",
|
metavar="host:port",
|
||||||
help=f"FastAPI backend endpoint (default: localhost:{DEFAULT_DEV_PORT})",
|
help=f"FastAPI backend endpoint (default: localhost:{DEFAULT_DEV_PORT})",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args, extra_args = parser.parse_known_args()
|
||||||
with contextlib.suppress(KeyboardInterrupt):
|
with suppress(KeyboardInterrupt):
|
||||||
asyncio.run(run_devserver(args.frontend, args.backend))
|
asyncio.run(run_devserver(args.frontend, args.backend, extra_args))
|
||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
scripts/devserver.py # Default ports on localhost
|
scripts/devserver.py [args to PROJECT_CLI]
|
||||||
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
|
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).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ def find_dev_tool() -> list[str]:
|
|||||||
Raises RuntimeError if no runtime is found.
|
Raises RuntimeError if no runtime is found.
|
||||||
"""
|
"""
|
||||||
dev_args = {
|
dev_args = {
|
||||||
"deno": ("run", "dev", "--"),
|
"deno": ("run", "-A", "npm:vite"),
|
||||||
"npm": ("--silent", "run", "dev", "--"),
|
"npm": ("--silent", "run", "dev", "--"),
|
||||||
"bun": ("run", "dev", "--"),
|
"bun": ("run", "dev", "--"),
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ def build(folder: str = "frontend") -> None:
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
def run(cmd):
|
def run(cmd):
|
||||||
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||||
logger.info("### %s", " ".join(display_cmd))
|
logger.info("### %s", " ".join(display_cmd))
|
||||||
subprocess.run(cmd, check=True, cwd=folder)
|
subprocess.run(cmd, check=True, cwd=folder)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from collections.abc import Coroutine
|
from collections.abc import Coroutine
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -23,7 +24,7 @@ class ProcessGroup:
|
|||||||
self, *cmd: str, cwd: str | None = None
|
self, *cmd: str, cwd: str | None = None
|
||||||
) -> asyncio.subprocess.Process:
|
) -> asyncio.subprocess.Process:
|
||||||
"""Spawn a subprocess and track it."""
|
"""Spawn a subprocess and track it."""
|
||||||
cmd_name = Path(cmd[0]).name
|
cmd_name = Path(cmd[0]).stem
|
||||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||||
self._procs.append(proc)
|
self._procs.append(proc)
|
||||||
@@ -159,10 +160,42 @@ def setup_vite(
|
|||||||
|
|
||||||
def setup_fastapi(
|
def setup_fastapi(
|
||||||
endpoint: str, module: str, default_port: int = 8000
|
endpoint: str, module: str, default_port: int = 8000
|
||||||
) -> tuple[str, str, dict]:
|
) -> tuple[str, list[str]]:
|
||||||
"""Parse backend endpoint and build server.run() config.
|
"""Parse backend endpoint and build uvicorn command.
|
||||||
|
|
||||||
Returns (url, module, config_dict).
|
Returns (url, uvicorn_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"]
|
||||||
|
reload_dir = module.split(".")[0] # Don't reload on frontend changes
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"uvicorn",
|
||||||
|
module,
|
||||||
|
f"--host={host}",
|
||||||
|
f"--port={port}",
|
||||||
|
"--reload",
|
||||||
|
f"--reload-dir={reload_dir}",
|
||||||
|
"--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.
|
Raises SystemExit(1) on invalid config.
|
||||||
"""
|
"""
|
||||||
endpoints = parse_endpoint(endpoint, default_port)
|
endpoints = parse_endpoint(endpoint, default_port)
|
||||||
@@ -174,10 +207,10 @@ def setup_fastapi(
|
|||||||
host = endpoints[0]["host"]
|
host = endpoints[0]["host"]
|
||||||
port = endpoints[0]["port"]
|
port = endpoints[0]["port"]
|
||||||
|
|
||||||
config = {
|
cmd = [
|
||||||
"listen": f"{host}:{port}",
|
sys.executable,
|
||||||
"reload": True,
|
"-m",
|
||||||
"reload_dirs": [module.split(".")[0]], # Don't reload on frontend changes
|
cli,
|
||||||
"forwarded_allow_ips": "*",
|
f"--listen={host}:{port}",
|
||||||
}
|
]
|
||||||
return f"http://{host}:{port}", module, config
|
return f"http://{host}:{port}", cmd
|
||||||
|
|||||||
Reference in New Issue
Block a user