Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c63a375ab2 | ||
|
|
c98994fd1a | ||
|
|
6beaa88418 | ||
|
|
26a77dcf84 | ||
|
|
38c9ffe00b | ||
|
|
b87984af9a | ||
|
|
d3b6fccce0 | ||
|
|
d2ee7395c5 | ||
|
|
b94608ffe3 | ||
|
|
ef6bce8f3f | ||
|
|
b8bf46132a | ||
|
|
ca85c2fcfb | ||
|
|
440808361a | ||
|
|
05fd5c562e | ||
|
|
5f0e74713f | ||
|
|
2d117a9d5e | ||
|
|
4bf102579a | ||
|
|
fbed7873e5 | ||
|
|
008b3593db | ||
|
|
3820c6f3c1 | ||
|
|
c300a1a291 | ||
|
|
bdfaa22b0c | ||
|
|
47e5ec279c | ||
|
|
093b83737e | ||
|
|
a84041a923 | ||
|
|
9a9a00380f | ||
|
|
0e4f8f65f2 | ||
|
|
5391c81e8b | ||
|
|
c106ce1a44 | ||
|
|
b7b1920846 | ||
|
|
8ccfa1f658 |
@@ -0,0 +1,47 @@
|
|||||||
|
# fastapi-vue
|
||||||
|
|
||||||
|
Implements Single-Page-App serving at site root with FastAPI, that the standard StaticFiles module cannot handle. This also caches and zstd compresses the files for lightning-fast operation. This is primarily meant for use with Vue frontend, but technically can host any static files in a similar manner.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Script [fastapi-vue-setup](https://git.zi.fi/LeoVasanko/fastapi-vue-setup) should normally be used to convert or create a project with connection between FastAPI and Vue. The target project will depend on this package to serve its static files.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uvx fastapi-vue-setup --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Refer to instructions below for further configuration.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi_vue import Frontend
|
||||||
|
|
||||||
|
frontend = Frontend(
|
||||||
|
Path(__file__).with_name("frontend-build"),
|
||||||
|
spa=True,
|
||||||
|
cached=["/assets/"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await frontend.load()
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
# Add API routes here...
|
||||||
|
|
||||||
|
# Final catch-all route for frontend files (keep at end of file)
|
||||||
|
frontend.route(app, "/")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- `directory`: Path to static files directory
|
||||||
|
- `spa`: Enable SPA mode (serve index.html for unknown routes)
|
||||||
|
- `cached`: Path prefixes for immutable cache headers (browser won't check for changes)
|
||||||
|
- `favicon`: Path to serve at `/favicon.ico` (e.g., `"/logo.png"` will be served as `image/png`)
|
||||||
|
- `zstdlevel`: Compression level (default: 18)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .staticfiles import Frontend
|
||||||
|
|
||||||
|
__all__ = ["Frontend"]
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import contextlib
|
||||||
|
import ipaddress
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def parse_endpoint(value: str | None, default_port: int = 0) -> list[dict]:
|
||||||
|
"""Parse an endpoint string into uvicorn bind configurations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Endpoint string to parse
|
||||||
|
default_port: Port to use when not specified
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with uvicorn bind kwargs (host/port or uds).
|
||||||
|
|
||||||
|
Supported forms:
|
||||||
|
- None or empty -> [{host: "localhost", port: default_port}]
|
||||||
|
- port (numeric) -> [{host: "localhost", port: port}]
|
||||||
|
- :port -> [{host: "0.0.0.0", port}, {host: "::", port}] (all interfaces)
|
||||||
|
- host:port -> [{host, port}]
|
||||||
|
- host -> [{host, port: default_port}]
|
||||||
|
- [ipv6]:port -> [{host: ipv6, port}]
|
||||||
|
- ipv6 (unbracketed) -> [{host: ipv6, port: default_port}]
|
||||||
|
- /path or unix:/path -> [{uds: path}]
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return [{"host": "localhost", "port": default_port}]
|
||||||
|
|
||||||
|
# Port only (numeric) -> localhost:port
|
||||||
|
if value.isdigit():
|
||||||
|
return [{"host": "localhost", "port": int(value)}]
|
||||||
|
|
||||||
|
# Leading colon :port -> bind all interfaces (0.0.0.0 + ::)
|
||||||
|
if value.startswith(":") and value != ":":
|
||||||
|
port_part = value[1:]
|
||||||
|
if not port_part.isdigit():
|
||||||
|
raise SystemExit(f"Invalid port in '{value}'")
|
||||||
|
port = int(port_part)
|
||||||
|
return [{"host": "0.0.0.0", "port": port}, {"host": "::", "port": port}] # noqa: S104
|
||||||
|
|
||||||
|
# UNIX domain socket (unix:/path or just /path)
|
||||||
|
if value.startswith("/"):
|
||||||
|
return [{"uds": value}]
|
||||||
|
if value.startswith("unix:"):
|
||||||
|
uds_path = value[5:] or None
|
||||||
|
if uds_path is None:
|
||||||
|
raise SystemExit("unix: path must not be empty")
|
||||||
|
return [{"uds": uds_path}]
|
||||||
|
|
||||||
|
# Unbracketed IPv6 (cannot safely contain a port) -> detect by multiple colons
|
||||||
|
if value.count(":") > 1 and not value.startswith("["):
|
||||||
|
try:
|
||||||
|
ipaddress.IPv6Address(value)
|
||||||
|
except ValueError as e:
|
||||||
|
raise SystemExit(f"Invalid IPv6 address '{value}': {e}") from e
|
||||||
|
return [{"host": value, "port": default_port}]
|
||||||
|
|
||||||
|
# Use urllib.parse for everything else (host[:port], [ipv6][:port])
|
||||||
|
parsed = urlparse(f"//{value}") # // prefix lets urlparse treat it as netloc
|
||||||
|
host = parsed.hostname or "localhost"
|
||||||
|
port = parsed.port or default_port
|
||||||
|
|
||||||
|
# Validate IP literals (optional; hostname passes through)
|
||||||
|
with contextlib.suppress(ValueError):
|
||||||
|
ipaddress.ip_address(host)
|
||||||
|
|
||||||
|
return [{"host": host, "port": port}]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from uvicorn import Config, Server
|
||||||
|
|
||||||
|
from .hostutil import parse_endpoint
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
app: str,
|
||||||
|
*,
|
||||||
|
listen: str | list[str] | None = None,
|
||||||
|
default_port: int = 8000,
|
||||||
|
reload: bool = False,
|
||||||
|
workers: int | None = None,
|
||||||
|
**uvicorn_config,
|
||||||
|
):
|
||||||
|
"""Run uvicorn server(s) for the given app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: The ASGI application path (e.g., "myapp.main:app")
|
||||||
|
listen: Endpoint string(s) (see parse_endpoint for formats).
|
||||||
|
default_port: Port to use when not specified in listen args.
|
||||||
|
reload: Enable auto-reload (requires uvicorn.run, single endpoint only).
|
||||||
|
workers: Number of worker processes (requires uvicorn.run, single endpoint only).
|
||||||
|
**uvicorn_config: Additional uvicorn config options (overrides all other settings).
|
||||||
|
"""
|
||||||
|
if listen is None:
|
||||||
|
listen = [f"localhost:{default_port}"]
|
||||||
|
elif isinstance(listen, str):
|
||||||
|
listen = [listen]
|
||||||
|
endpoints: list[dict] = []
|
||||||
|
for ep in listen:
|
||||||
|
endpoints.extend(parse_endpoint(ep, default_port))
|
||||||
|
|
||||||
|
conf: dict[str, object] = {"app": app, "reload": reload, "workers": workers}
|
||||||
|
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
||||||
|
if proxy:
|
||||||
|
conf["proxy_headers"] = True
|
||||||
|
conf["forwarded_allow_ips"] = proxy
|
||||||
|
conf.update(uvicorn_config)
|
||||||
|
|
||||||
|
with suppress(KeyboardInterrupt):
|
||||||
|
if reload or workers:
|
||||||
|
serve_multiprocess(endpoints, **conf)
|
||||||
|
else:
|
||||||
|
asyncio.run(serve(endpoints, **conf))
|
||||||
|
|
||||||
|
|
||||||
|
async def serve(endpoints: list[dict], **kwargs) -> None:
|
||||||
|
"""Serve the given endpoints in current process/loop. Does not spawn extra processes."""
|
||||||
|
forbidden = {"reload", "workers"} & {k for k, v in kwargs.items() if v}
|
||||||
|
if forbidden:
|
||||||
|
logger.warning(
|
||||||
|
"Options %s have no effect in simple mode (multiple endpoints)",
|
||||||
|
", ".join(sorted(forbidden)),
|
||||||
|
)
|
||||||
|
await asyncio.gather(*(Server(Config(**kwargs, **ep)).serve() for ep in endpoints))
|
||||||
|
|
||||||
|
|
||||||
|
def serve_multiprocess(endpoints: list[dict], **kwargs) -> None:
|
||||||
|
"""Serve using uvicorn.run() for reload/workers support. Only first endpoint is used."""
|
||||||
|
if len(endpoints) > 1:
|
||||||
|
eps = [
|
||||||
|
ep["uds"] if "uds" in ep else f"{ep['host']}:{ep['port']}"
|
||||||
|
for ep in endpoints
|
||||||
|
]
|
||||||
|
logger.warning(
|
||||||
|
"Current mode supports only one endpoint. Listening: %s, skipped: %s",
|
||||||
|
eps[0],
|
||||||
|
" ".join(eps[1:]),
|
||||||
|
)
|
||||||
|
uvicorn.run(**kwargs, **endpoints[0])
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""FastAPI static file serving with zstd compression and SPA support."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import time
|
||||||
|
from base64 import urlsafe_b64encode
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path, PurePath, PurePosixPath
|
||||||
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
|
from blake3 import blake3
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.routing import Route
|
||||||
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Static file server with automatic zstd compression and caching.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Automatic zstd compression for compressible files
|
||||||
|
- ETag-based caching of immutable assets
|
||||||
|
- SPA (Single Page Application) support
|
||||||
|
- /favicon.ico with correct MIME type (image/png etc)
|
||||||
|
- Dev mode: indexes files but returns error directing to Vite server
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Path to the directory containing static files
|
||||||
|
index: Name of the index file (default: "index.html")
|
||||||
|
spa: Enable SPA mode - serve index.html for unknown routes (default: False)
|
||||||
|
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)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
directory: Path | str,
|
||||||
|
*,
|
||||||
|
index: str = "index.html",
|
||||||
|
spa: bool = False,
|
||||||
|
catch_all: bool | None = None,
|
||||||
|
cached: str | list[str] | Assets = Assets(),
|
||||||
|
favicon: str | None = None,
|
||||||
|
zstdlevel: int = 18,
|
||||||
|
) -> None:
|
||||||
|
self.www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
||||||
|
self.base: Path = Path(directory)
|
||||||
|
self.index = index
|
||||||
|
self.spa = spa
|
||||||
|
self._catch_all = spa if catch_all is None else catch_all
|
||||||
|
self.cached_paths = Assets.parse(cached)
|
||||||
|
self.zstdlevel = zstdlevel
|
||||||
|
self.favicon = favicon
|
||||||
|
self._app: FastAPI | None = None
|
||||||
|
self._mount_path: str = ""
|
||||||
|
self._ridx: int = 0
|
||||||
|
self._routes: list[Route] = []
|
||||||
|
|
||||||
|
def _index_only(self) -> set[str]:
|
||||||
|
"""Index file paths without loading content (for dev mode)."""
|
||||||
|
paths: set[str] = set()
|
||||||
|
if not self.base.exists():
|
||||||
|
return paths
|
||||||
|
queue = [PurePath()]
|
||||||
|
while queue:
|
||||||
|
current = self.base / queue.pop(0)
|
||||||
|
for p in current.iterdir():
|
||||||
|
rel = p.relative_to(self.base)
|
||||||
|
if p.is_dir():
|
||||||
|
queue.append(rel)
|
||||||
|
continue
|
||||||
|
name = "/" + rel.as_posix()
|
||||||
|
name = name.removesuffix(self.index)
|
||||||
|
paths.add(name)
|
||||||
|
if self.favicon:
|
||||||
|
p = PurePosixPath(self.favicon)
|
||||||
|
base = str(p.with_suffix(""))
|
||||||
|
ext = p.suffix
|
||||||
|
if any(path.startswith(base) and path.endswith(ext) for path in paths):
|
||||||
|
paths.add("/favicon.ico")
|
||||||
|
return paths
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
"""Load static files from disk with compression."""
|
||||||
|
www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
||||||
|
if not self.base.exists():
|
||||||
|
raise ValueError(f"Frontend folder {self.base} not found (try uv build)")
|
||||||
|
paths = [PurePath()]
|
||||||
|
while paths:
|
||||||
|
current = self.base / paths.pop(0)
|
||||||
|
for p in current.iterdir():
|
||||||
|
rel = p.relative_to(self.base)
|
||||||
|
if p.is_dir():
|
||||||
|
paths.append(rel)
|
||||||
|
continue
|
||||||
|
# Read file
|
||||||
|
name = "/" + rel.as_posix()
|
||||||
|
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||||
|
name = name.removesuffix(self.index)
|
||||||
|
data = p.read_bytes()
|
||||||
|
etag = urlsafe_b64encode(blake3(data).digest(9)).decode()
|
||||||
|
if mime.startswith("text/"):
|
||||||
|
mime += "; charset=UTF-8"
|
||||||
|
mtime = p.stat().st_mtime
|
||||||
|
cached = any(name.startswith(prefix) for prefix in self.cached_paths)
|
||||||
|
headers = {
|
||||||
|
"etag": f'"{etag}"',
|
||||||
|
"last-modified": format_date_time(mtime),
|
||||||
|
"cache-control": (
|
||||||
|
"max-age=31536000, immutable" if cached else "no-cache"
|
||||||
|
),
|
||||||
|
"content-type": mime,
|
||||||
|
}
|
||||||
|
zstd = ZstdCompressor(self.zstdlevel).compress(data)
|
||||||
|
if len(zstd) >= len(data):
|
||||||
|
zstd = None
|
||||||
|
www[name] = data, zstd, headers
|
||||||
|
if self.favicon:
|
||||||
|
if m := fnmatch.filter(www, self.favicon):
|
||||||
|
data, zstd, headers = www[m[0]]
|
||||||
|
if "immutable" in headers.get("cache-control", ""):
|
||||||
|
headers = {**headers, "cache-control": "max-age=86400"}
|
||||||
|
www["/favicon.ico"] = data, zstd, headers
|
||||||
|
if not www:
|
||||||
|
msg = "Frontend files missing, check your installation.\n"
|
||||||
|
www["/"] = (
|
||||||
|
msg.encode(),
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"etag": "error",
|
||||||
|
"content-type": "text/plain",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return www
|
||||||
|
|
||||||
|
async def load(self, *, debug: bool | None = None, log: bool = True):
|
||||||
|
"""Load or reload static files from disk.
|
||||||
|
|
||||||
|
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 debug is None:
|
||||||
|
debug = getattr(self._app, "debug", False)
|
||||||
|
if debug:
|
||||||
|
# Dev mode: just index paths, no content loading
|
||||||
|
self._devmode_paths = await run_in_threadpool(self._index_only)
|
||||||
|
self._register_routes()
|
||||||
|
return
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
self.www = await run_in_threadpool(self._load)
|
||||||
|
self._register_routes()
|
||||||
|
duration = time.perf_counter() - start
|
||||||
|
if not log:
|
||||||
|
return
|
||||||
|
compfiles = [(len(d), len(z)) for d, z, _ in self.www.values() if z]
|
||||||
|
raw = sum(v[0] for v in compfiles)
|
||||||
|
comp = sum(v[1] for v in compfiles)
|
||||||
|
ratio = comp / raw * 100 if raw else 100.0
|
||||||
|
if log and self.www:
|
||||||
|
logger.info(
|
||||||
|
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} %)"
|
||||||
|
)
|
||||||
|
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="/"):
|
||||||
|
"""Register frontend routes with a FastAPI app.
|
||||||
|
|
||||||
|
In SPA/catch-all mode, this must only be called only after all other routes.
|
||||||
|
|
||||||
|
The calling position determines routing priority, although in regular mode the
|
||||||
|
routes are actually added only after load() is called.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: FastAPI application instance
|
||||||
|
mount_path: Path where the frontend should be mounted (default: "/")
|
||||||
|
"""
|
||||||
|
self._app = app
|
||||||
|
self._mount_path = mount_path.rstrip("/")
|
||||||
|
self._ridx = len(app.routes)
|
||||||
|
|
||||||
|
if self._catch_all:
|
||||||
|
# Register catch-all immediately (works without load)
|
||||||
|
path = self._mount_path + "{path:path}"
|
||||||
|
app.api_route(path, methods=["GET", "HEAD"], name="frontend")(self.handle)
|
||||||
|
|
||||||
|
def _register_routes(self):
|
||||||
|
"""Register individual routes for each loaded file (non-catch_all mode)."""
|
||||||
|
if self._app is None or self._catch_all:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove previously registered routes (for reload support)
|
||||||
|
for route in list(self._routes):
|
||||||
|
if route in self._app.routes:
|
||||||
|
self._app.routes.remove(route)
|
||||||
|
self._routes.clear()
|
||||||
|
|
||||||
|
# Get paths and select handler based on mode (checked once, not per request)
|
||||||
|
debug = getattr(self._app, "debug", False)
|
||||||
|
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
|
||||||
|
self._app.routes[self._ridx : self._ridx] = self._routes = [
|
||||||
|
Route(
|
||||||
|
self._mount_path + p,
|
||||||
|
endpoint=handler if debug else partial(handler, name=p),
|
||||||
|
methods=["GET", "HEAD"],
|
||||||
|
name=f"frontend{p.replace('/', '_')}",
|
||||||
|
)
|
||||||
|
for p in paths
|
||||||
|
]
|
||||||
|
|
||||||
|
def _respond(self, request: Request, name: str):
|
||||||
|
"""Serve a static file with ETag and compression support."""
|
||||||
|
data, zstd, headers = self.www[name]
|
||||||
|
if request.headers.get("if-none-match") == headers["etag"]:
|
||||||
|
return Response(status_code=304, headers=headers)
|
||||||
|
if zstd and "zstd" in request.headers.get("accept-encoding", ""):
|
||||||
|
return Response(
|
||||||
|
content=zstd, headers={**headers, "content-encoding": "zstd"}
|
||||||
|
)
|
||||||
|
return Response(content=data, headers=headers)
|
||||||
|
|
||||||
|
def handle(self, request: Request, path: str):
|
||||||
|
"""SPA catch-all handler with directory redirects and fallback to index."""
|
||||||
|
name = path.removesuffix(self.index)
|
||||||
|
debug = getattr(self._app, "debug", False)
|
||||||
|
files = self._devmode_paths if debug else self.www
|
||||||
|
|
||||||
|
if name not in files:
|
||||||
|
# Friendly redirect for directories missing trailing slash
|
||||||
|
if name and f"{name}/" in files:
|
||||||
|
return RedirectResponse(request.url.path + "/")
|
||||||
|
# SPA support: serve / for unknown paths if the browser wants HTML
|
||||||
|
if self.spa and "text/html" in request.headers.get("accept", ""):
|
||||||
|
name = "/"
|
||||||
|
# 404 for everything else
|
||||||
|
if name not in files:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
|
return (_devmode_respond if debug else self._respond)(request, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _devmode_respond(request: Request, name=""):
|
||||||
|
"""Return error response directing to Vite server."""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=409,
|
||||||
|
content={
|
||||||
|
"detail": "Frontend assets served by Vite in debug mode. You are on backend, connect to frontend instead."
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[project]
|
||||||
|
name = "fastapi-vue"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = "Serves Vue assets on a FastAPI app. Use fastapi-vue-setup tool to add Vue build to your package."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"zstandard>=0.23.0",
|
||||||
|
"blake3>=1.0.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://git.zi.fi/LeoVasanko/fastapi-vue"
|
||||||
|
Repository = "https://github.com/LeoVasanko/fastapi-vue"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling", "hatch-vcs"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
source = "vcs"
|
||||||
|
raw-options.root = ".."
|
||||||
+422
-125
@@ -7,10 +7,13 @@ Usage:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--module-name NAME Python module name (auto-detected from pyproject.toml)
|
--module-name NAME Python module name (auto-detected from pyproject.toml)
|
||||||
--dry-run Show what would be done without making changes
|
--ports DEFAULT,VITE,DEV Port configuration (default: 3100,3100,3200)
|
||||||
|
--dry Show what would be done without making changes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import ast
|
||||||
|
import importlib.metadata
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -21,9 +24,60 @@ from textwrap import indent
|
|||||||
|
|
||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
|
version = importlib.metadata.version("fastapi-vue-setup")
|
||||||
|
|
||||||
|
# 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 print_boxed(text: str) -> None:
|
||||||
|
"""Print text in a Unicode rounded box."""
|
||||||
|
width = len(text) + 2
|
||||||
|
print(f"╭{'─' * width}╮")
|
||||||
|
print(f"│ {text} │")
|
||||||
|
print(f"╰{'─' * width}╯")
|
||||||
|
|
||||||
|
|
||||||
|
def ruff_sort_imports(files: list[Path], dry: 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:
|
||||||
|
print(f"🔧 Would run ruff import sorting on {len(py_files)} files")
|
||||||
|
return
|
||||||
|
print("🔧 Ruff isort on modified files")
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-m", "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)
|
||||||
|
# If vite == dev, dev is incremented by 100
|
||||||
|
DEFAULT_PORTS = (3100, 3100, 3200)
|
||||||
|
|
||||||
# Marker comment indicating file can be auto-upgraded
|
# Marker comment indicating file can be auto-upgraded
|
||||||
# Users should remove this line to prevent automatic updates
|
# Users should remove this line to prevent automatic updates
|
||||||
UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup"
|
UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup"
|
||||||
@@ -52,7 +106,16 @@ PYPROJECT_ADDITIONS = {
|
|||||||
# Frontend instantiation block for patching existing apps
|
# Frontend instantiation block for patching existing apps
|
||||||
FRONTEND_BLOCK = """
|
FRONTEND_BLOCK = """
|
||||||
# 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"))
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Lifespan block for patching apps that don't have one
|
||||||
|
LIFESPAN_BLOCK = """
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
\"\"\"Manage app startup and shutdown resources.\"\"\"
|
||||||
|
await frontend.load()
|
||||||
|
yield
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# TypeScript health check script for Vue components
|
# TypeScript health check script for Vue components
|
||||||
@@ -63,7 +126,7 @@ const backendStatus = ref<'checking' | 'connected' | 'error'>('checking')
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/health')
|
const res = await fetch('/api/health?from=frontend')
|
||||||
backendStatus.value = res.ok ? 'connected' : 'error'
|
backendStatus.value = res.ok ? 'connected' : 'error'
|
||||||
} catch {
|
} catch {
|
||||||
backendStatus.value = 'error'
|
backendStatus.value = 'error'
|
||||||
@@ -79,7 +142,7 @@ const backendStatus = ref('checking')
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/health')
|
const res = await fetch('/api/health?from=frontend')
|
||||||
backendStatus.value = res.ok ? 'connected' : 'error'
|
backendStatus.value = res.ok ? 'connected' : 'error'
|
||||||
} catch {
|
} catch {
|
||||||
backendStatus.value = 'error'
|
backendStatus.value = 'error'
|
||||||
@@ -98,17 +161,16 @@ STATUS_SPAN_TEMPLATE = """\
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Setup complete message template
|
# Setup complete message template
|
||||||
SETUP_COMPLETE_MESSAGE = """
|
SETUP_COMPLETE_MESSAGE = """\
|
||||||
Next steps:
|
## Development server: (live reloads, debug)
|
||||||
|
CD_CMDuv run scripts/devserver.py
|
||||||
|
|
||||||
1. Build for production:
|
## Production build:
|
||||||
CD_CMDuv build
|
CD_CMDuv build && uv run SCRIPT_NAME
|
||||||
|
|
||||||
2. Start development server:
|
## Release Python package, run anywhere:
|
||||||
CD_CMDuv run scripts/devserver.py
|
CD_CMDuv build && uv publish
|
||||||
|
uvx SCRIPT_NAME # No Node required
|
||||||
3. Run production server:
|
|
||||||
CD_CMDuv run SCRIPT_NAME
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +179,98 @@ Next steps:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ports(ports_str: str | None) -> tuple[int, int, int]:
|
||||||
|
"""Parse comma-separated port string into (default, vite, dev) tuple.
|
||||||
|
|
||||||
|
If dev == vite, dev is incremented by 100 to avoid conflicts.
|
||||||
|
"""
|
||||||
|
if not ports_str:
|
||||||
|
return DEFAULT_PORTS
|
||||||
|
|
||||||
|
parts = ports_str.split(",")
|
||||||
|
if len(parts) == 1:
|
||||||
|
default = int(parts[0])
|
||||||
|
vite = default
|
||||||
|
dev = default + 100
|
||||||
|
elif len(parts) == 2:
|
||||||
|
default = int(parts[0])
|
||||||
|
vite = int(parts[1])
|
||||||
|
dev = vite + 100 if vite == default else default + 100
|
||||||
|
elif len(parts) == 3:
|
||||||
|
default = int(parts[0])
|
||||||
|
vite = int(parts[1])
|
||||||
|
dev = int(parts[2])
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid ports format: {ports_str}")
|
||||||
|
|
||||||
|
# Auto-adjust dev if it conflicts with vite
|
||||||
|
if dev == vite:
|
||||||
|
dev = vite + 100
|
||||||
|
|
||||||
|
return default, vite, dev
|
||||||
|
|
||||||
|
|
||||||
|
def find_import_insertion_line(source: str) -> int:
|
||||||
|
"""Find line number (1-based) for inserting imports, after shebang/docstring."""
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source)
|
||||||
|
except SyntaxError:
|
||||||
|
return 2 if source.startswith("#!") else 1
|
||||||
|
# Find first import, or end of docstring if no imports
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||||
|
return node.lineno
|
||||||
|
if not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)):
|
||||||
|
break # Non-import, non-docstring statement
|
||||||
|
# No imports found - insert after docstring or at line 1
|
||||||
|
if tree.body and isinstance(tree.body[0], ast.Expr):
|
||||||
|
return tree.body[0].end_lineno + 1
|
||||||
|
return 2 if source.startswith("#!") else 1
|
||||||
|
|
||||||
|
|
||||||
|
def extract_existing_ports(project_dir: Path) -> tuple[int, int, int] | None:
|
||||||
|
"""Extract existing port configuration from project files.
|
||||||
|
|
||||||
|
Returns (default, vite, dev) or None if not found.
|
||||||
|
"""
|
||||||
|
module_name = find_module_name(project_dir)
|
||||||
|
if not module_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
default_port = None
|
||||||
|
vite_port = None
|
||||||
|
dev_port = None
|
||||||
|
|
||||||
|
# Try to extract DEFAULT_PORT from __main__.py
|
||||||
|
main_file = project_dir / module_name / "__main__.py"
|
||||||
|
if main_file.exists():
|
||||||
|
content = main_file.read_text("UTF-8")
|
||||||
|
match = re.search(r"DEFAULT_PORT\s*=\s*(\d+)", content)
|
||||||
|
if match:
|
||||||
|
default_port = int(match.group(1))
|
||||||
|
|
||||||
|
# Try to extract ports from devserver.py
|
||||||
|
devserver_file = project_dir / "scripts" / "devserver.py"
|
||||||
|
if devserver_file.exists():
|
||||||
|
content = devserver_file.read_text("UTF-8")
|
||||||
|
match = re.search(r"DEFAULT_VITE_PORT\s*=\s*(\d+)", content)
|
||||||
|
if match:
|
||||||
|
vite_port = int(match.group(1))
|
||||||
|
match = re.search(r"DEFAULT_DEV_PORT\s*=\s*(\d+)", content)
|
||||||
|
if match:
|
||||||
|
dev_port = int(match.group(1))
|
||||||
|
|
||||||
|
# Return only if we found at least one port
|
||||||
|
if default_port is not None or vite_port is not None or dev_port is not None:
|
||||||
|
return (
|
||||||
|
default_port or DEFAULT_PORTS[0],
|
||||||
|
vite_port or DEFAULT_PORTS[1],
|
||||||
|
dev_port or DEFAULT_PORTS[2],
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
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("UTF-8")
|
return (TEMPLATE_DIR / path).read_text("UTF-8")
|
||||||
@@ -338,11 +492,11 @@ def render_template(template: str, **kwargs) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def patch_app_file(
|
def patch_app_file(
|
||||||
path: Path, module_name: str, app_var: str, dry_run: bool = False
|
path: Path, module_name: str, app_var: str, dry: bool = False
|
||||||
) -> 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.
|
||||||
@@ -352,73 +506,156 @@ 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
|
||||||
|
has_lifespan = "await frontend.load()" in content
|
||||||
|
|
||||||
|
if has_frontend and has_devmode and has_debug_arg and has_lifespan:
|
||||||
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)
|
||||||
|
content = "\n".join(lines)
|
||||||
|
|
||||||
# Check if we found imports to insert after
|
# Append route at end (only if not already present)
|
||||||
if last_import_idx == 0 and not lines[0].strip().startswith(("import ", "from ")):
|
if route_line not in content:
|
||||||
print(f"⚠️ Skipping {path} (no imports found to patch)")
|
lines = content.split("\n")
|
||||||
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
|
|
||||||
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 last argument
|
||||||
|
if args.strip():
|
||||||
|
new_args = f"{args}, debug=DEVMODE"
|
||||||
|
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
|
||||||
|
|
||||||
|
# No lifespan at all: create one and wire it into FastAPI()
|
||||||
|
if not lifespan_patched and f"@{app_var}.on_event" not in content:
|
||||||
|
# Add contextlib import
|
||||||
|
if "from contextlib import asynccontextmanager" not in content:
|
||||||
|
insert_line = find_import_insertion_line(content)
|
||||||
|
lines = content.splitlines(keepends=True)
|
||||||
|
insert_idx = insert_line - 1
|
||||||
|
import_text = "from contextlib import asynccontextmanager\n"
|
||||||
|
if insert_idx >= len(lines):
|
||||||
|
content = content.rstrip("\n") + "\n" + import_text
|
||||||
|
else:
|
||||||
|
content = (
|
||||||
|
"".join(lines[:insert_idx])
|
||||||
|
+ import_text
|
||||||
|
+ "".join(lines[insert_idx:])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert lifespan block before the FastAPI() call
|
||||||
|
fastapi_line_pattern = r"^(\w+\s*=\s*FastAPI\s*\()"
|
||||||
|
fastapi_match = re.search(fastapi_line_pattern, content, re.MULTILINE)
|
||||||
|
if fastapi_match:
|
||||||
|
content = (
|
||||||
|
content[: fastapi_match.start()]
|
||||||
|
+ LIFESPAN_BLOCK.lstrip("\n")
|
||||||
|
+ "\n"
|
||||||
|
+ content[fastapi_match.start() :]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add lifespan=lifespan to FastAPI() call
|
||||||
|
fastapi_pattern = r"(\w+\s*=\s*FastAPI\s*\()([^)]*)\)"
|
||||||
|
fastapi_match = re.search(fastapi_pattern, content, re.DOTALL)
|
||||||
|
if fastapi_match and "lifespan" not in fastapi_match.group(2):
|
||||||
|
args = fastapi_match.group(2)
|
||||||
|
if args.strip():
|
||||||
|
new_args = f"{args}, lifespan=lifespan"
|
||||||
|
else:
|
||||||
|
new_args = "lifespan=lifespan"
|
||||||
|
content = (
|
||||||
|
content[: fastapi_match.start()]
|
||||||
|
+ fastapi_match.group(1)
|
||||||
|
+ new_args
|
||||||
|
+ ")"
|
||||||
|
+ content[fastapi_match.end() :]
|
||||||
|
)
|
||||||
|
lifespan_patched = True
|
||||||
|
|
||||||
# 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:
|
||||||
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:
|
||||||
@@ -442,7 +679,7 @@ def patch_app_file(
|
|||||||
def patch_vite_config(
|
def patch_vite_config(
|
||||||
path: Path,
|
path: Path,
|
||||||
module_name: str,
|
module_name: str,
|
||||||
dry_run: bool = False,
|
dry: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Patch an existing vite.config.js/ts by adding fastapi-vue plugin.
|
"""Patch an existing vite.config.js/ts by adding fastapi-vue plugin.
|
||||||
|
|
||||||
@@ -501,11 +738,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:
|
||||||
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")
|
||||||
@@ -513,7 +750,7 @@ def patch_vite_config(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bool:
|
def patch_frontend_health_check(frontend_dir: Path, dry: bool = False) -> bool:
|
||||||
"""Patch Vue app to include FastAPI backend health check.
|
"""Patch Vue app to include FastAPI backend health check.
|
||||||
|
|
||||||
Tries HelloWorld.vue first (full demo), then falls back to App.vue (minimal).
|
Tries HelloWorld.vue first (full demo), then falls back to App.vue (minimal).
|
||||||
@@ -539,7 +776,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")
|
||||||
@@ -584,7 +821,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>")
|
||||||
@@ -601,11 +838,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:
|
||||||
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")
|
||||||
@@ -613,6 +850,40 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# SHA-256 of old vite-plugin-fastapi.js (before auto-upgrade marker was added)
|
||||||
|
# with module name replaced by MODULE_NAME in the outDir path
|
||||||
|
_OLD_VITE_PLUGIN_SHA256 = (
|
||||||
|
"93713e879c15a25c750a70ce1de684adeaf11b0c723c38da56e5e7ba207f6632"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _upgrade_old_vite_plugin(path: Path, module_name: str, dry: bool = False) -> None:
|
||||||
|
"""Remove old vite-plugin-fastapi.js that lacks auto-upgrade marker.
|
||||||
|
|
||||||
|
Old versions didn't have the upgrade marker, so write_file skips them as
|
||||||
|
'customized by user'. We recognize the old version by normalizing the module
|
||||||
|
name in outDir and comparing the SHA-256 hash.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
content = path.read_text("UTF-8")
|
||||||
|
if UPGRADE_MARKER in content:
|
||||||
|
return # Already new format, write_file handles it
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
normalized = content.replace(
|
||||||
|
f"../{module_name}/frontend-build", "../MODULE_NAME/frontend-build"
|
||||||
|
)
|
||||||
|
digest = hashlib.sha256(normalized.encode()).hexdigest()
|
||||||
|
if digest != _OLD_VITE_PLUGIN_SHA256:
|
||||||
|
return # Modified by user, don't touch
|
||||||
|
if dry:
|
||||||
|
print(f"🔄 Would upgrade old {path}")
|
||||||
|
return
|
||||||
|
path.unlink()
|
||||||
|
print(f"🔄 Removing old {path} (will be replaced)")
|
||||||
|
|
||||||
|
|
||||||
# Track .new.py files written during setup (for merge notification)
|
# Track .new.py files written during setup (for merge notification)
|
||||||
_new_files_written: list[tuple[Path, Path]] = []
|
_new_files_written: list[tuple[Path, Path]] = []
|
||||||
|
|
||||||
@@ -621,7 +892,7 @@ def write_file(
|
|||||||
path: Path,
|
path: Path,
|
||||||
content: str,
|
content: str,
|
||||||
overwrite: bool = True,
|
overwrite: bool = True,
|
||||||
dry_run: bool = False,
|
dry: bool = False,
|
||||||
executable: bool = False,
|
executable: bool = False,
|
||||||
fallback_path: Path | None = None,
|
fallback_path: Path | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
@@ -635,7 +906,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
|
||||||
@@ -650,20 +921,22 @@ def write_file(
|
|||||||
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(
|
||||||
path, fallback_path, content, dry_run, executable
|
path, fallback_path, content, dry, executable
|
||||||
)
|
)
|
||||||
print(f"ℹ️ Skipping {path} (customized by user)")
|
print(f"ℹ️ Skipping {path} (customized by user)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if dry_run:
|
if dry:
|
||||||
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
|
||||||
@@ -673,7 +946,7 @@ def _write_fallback_file(
|
|||||||
original_path: Path,
|
original_path: Path,
|
||||||
fallback_path: Path,
|
fallback_path: Path,
|
||||||
content: str,
|
content: str,
|
||||||
dry_run: bool,
|
dry: bool,
|
||||||
executable: bool,
|
executable: bool,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""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."""
|
||||||
@@ -683,8 +956,8 @@ def _write_fallback_file(
|
|||||||
print(f"✔️ {fallback_path} (already up to date)")
|
print(f"✔️ {fallback_path} (already up to date)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if dry_run:
|
if dry:
|
||||||
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
|
||||||
|
|
||||||
@@ -692,6 +965,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
|
||||||
@@ -818,14 +1093,14 @@ def find_js_runtime() -> tuple[str, str] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def ensure_python_project(project_dir: Path, dry_run: bool = False) -> bool:
|
def ensure_python_project(project_dir: Path, dry: bool = False) -> bool:
|
||||||
"""Ensure pyproject.toml exists, run uv init if needed."""
|
"""Ensure pyproject.toml exists, run uv init if needed."""
|
||||||
pyproject = project_dir / "pyproject.toml"
|
pyproject = project_dir / "pyproject.toml"
|
||||||
if pyproject.exists():
|
if pyproject.exists():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if dry_run:
|
if dry:
|
||||||
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...")
|
||||||
@@ -844,7 +1119,7 @@ def ensure_python_project(project_dir: Path, dry_run: bool = False) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def ensure_frontend(project_dir: Path, dry_run: bool = False) -> bool:
|
def ensure_frontend(project_dir: Path, dry: bool = False) -> bool:
|
||||||
"""Ensure frontend directory exists with a Vue project, run create-vue if needed."""
|
"""Ensure frontend directory exists with a Vue project, run create-vue if needed."""
|
||||||
frontend_dir = project_dir / "frontend"
|
frontend_dir = project_dir / "frontend"
|
||||||
package_json = frontend_dir / "package.json"
|
package_json = frontend_dir / "package.json"
|
||||||
@@ -868,8 +1143,8 @@ 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:
|
||||||
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...")
|
||||||
@@ -908,27 +1183,28 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
project_dir = Path.cwd() / project_path
|
project_dir = Path.cwd() / project_path
|
||||||
|
|
||||||
project_dir = project_dir.resolve()
|
project_dir = project_dir.resolve()
|
||||||
dry_run = args.dry_run
|
dry = args.dry
|
||||||
|
|
||||||
|
print_boxed(f"fastapi-vue-setup {version}")
|
||||||
|
if dry:
|
||||||
|
print("🏃 DRY RUN MODE - no changes will be made\n")
|
||||||
|
|
||||||
# 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:
|
||||||
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}")
|
||||||
|
|
||||||
print(f"🔧 Setting up project: {project_dir}")
|
print(f"🔧 Setting up project: {project_dir}")
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print("\n🏃 DRY RUN MODE - no changes will be made\n")
|
|
||||||
|
|
||||||
# Step 1: Ensure frontend exists (do this first so cancellation doesn't leave partial setup)
|
# Step 1: Ensure frontend exists (do this first so cancellation doesn't leave partial setup)
|
||||||
if not ensure_frontend(project_dir, dry_run):
|
if not ensure_frontend(project_dir, dry):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Step 2: Ensure Python project exists
|
# Step 2: Ensure Python project exists
|
||||||
if not ensure_python_project(project_dir, dry_run):
|
if not ensure_python_project(project_dir, dry):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Detect module name
|
# Detect module name
|
||||||
@@ -936,17 +1212,40 @@ 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:
|
||||||
|
print(f"📦 Module: {module_name}")
|
||||||
|
|
||||||
|
# Determine port configuration
|
||||||
|
# Priority: --ports argument > existing project values > defaults
|
||||||
|
if args.ports:
|
||||||
|
default_port, vite_port, dev_port = parse_ports(args.ports)
|
||||||
|
ports_note = "(--ports)"
|
||||||
|
else:
|
||||||
|
existing_ports = extract_existing_ports(project_dir)
|
||||||
|
if existing_ports:
|
||||||
|
default_port, vite_port, dev_port = existing_ports
|
||||||
|
ports_note = "(kept for upgrade)"
|
||||||
|
else:
|
||||||
|
default_port, vite_port, dev_port = DEFAULT_PORTS
|
||||||
|
ports_note = "(--ports to override)"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"📡 Ports: default={default_port}, vite={vite_port}, dev={dev_port} {ports_note}"
|
||||||
|
)
|
||||||
|
|
||||||
# Title for templates
|
# Title for templates
|
||||||
project_title = module_name.replace("_", " ").title()
|
project_title = module_name.replace("_", " ").title()
|
||||||
|
|
||||||
print(f"📦 Module: {module_name}")
|
|
||||||
|
|
||||||
# Template variables
|
# Template variables
|
||||||
tpl_vars = {
|
tpl_vars = {
|
||||||
"MODULE_NAME": module_name,
|
"MODULE_NAME": module_name,
|
||||||
"PROJECT_TITLE": project_title,
|
"PROJECT_TITLE": project_title,
|
||||||
|
"TEMPLATE_DEFAULT_PORT": str(default_port),
|
||||||
|
"TEMPLATE_VITE_PORT": str(vite_port),
|
||||||
|
"TEMPLATE_DEV_PORT": str(dev_port),
|
||||||
|
"ENVPREFIX": module_name.upper(),
|
||||||
|
"PROJECT_CLI": module_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
module_dir = project_dir / module_name
|
module_dir = project_dir / module_name
|
||||||
@@ -971,7 +1270,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
tpl_vars["APP_MODULE"] = "app"
|
tpl_vars["APP_MODULE"] = "app"
|
||||||
|
|
||||||
# Create directories
|
# Create directories
|
||||||
if not dry_run:
|
if not dry:
|
||||||
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)
|
fastapi_vue_scripts.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -983,8 +1282,8 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
# Remove obsolete util.py if present
|
# Remove obsolete util.py if present
|
||||||
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:
|
||||||
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}")
|
||||||
@@ -1000,7 +1299,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
dest_path,
|
dest_path,
|
||||||
content,
|
content,
|
||||||
overwrite=True,
|
overwrite=True,
|
||||||
dry_run=dry_run,
|
dry=dry,
|
||||||
force=True, # Internal files, always overwrite
|
force=True, # Internal files, always overwrite
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1013,7 +1312,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
devserver_path,
|
devserver_path,
|
||||||
content,
|
content,
|
||||||
overwrite=True,
|
overwrite=True,
|
||||||
dry_run=dry_run,
|
dry=dry,
|
||||||
executable=True,
|
executable=True,
|
||||||
fallback_path=devserver_fallback,
|
fallback_path=devserver_fallback,
|
||||||
)
|
)
|
||||||
@@ -1021,7 +1320,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
# === Handle app module ===
|
# === Handle app module ===
|
||||||
if app_file:
|
if app_file:
|
||||||
# Existing app: patch with import, route, and try to patch lifespan
|
# Existing app: patch with import, route, and try to patch lifespan
|
||||||
patch_app_file(app_file, module_name, app_var, dry_run=dry_run)
|
patch_app_file(app_file, module_name, app_var, dry=dry)
|
||||||
else:
|
else:
|
||||||
# No app: create full app.py
|
# No app: create full app.py
|
||||||
# Create __init__.py if missing
|
# Create __init__.py if missing
|
||||||
@@ -1029,13 +1328,13 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
if not init_file.exists():
|
if not init_file.exists():
|
||||||
template = load_template("backend/__init__.py")
|
template = load_template("backend/__init__.py")
|
||||||
content = render_template(template, **tpl_vars)
|
content = render_template(template, **tpl_vars)
|
||||||
write_file(init_file, content, overwrite=False, dry_run=dry_run)
|
write_file(init_file, content, overwrite=False, dry=dry)
|
||||||
|
|
||||||
# Create app.py
|
# Create app.py
|
||||||
app_file_path = module_dir / "app.py"
|
app_file_path = module_dir / "app.py"
|
||||||
template = load_template("backend/app.py")
|
template = load_template("backend/app.py")
|
||||||
content = render_template(template, **tpl_vars)
|
content = render_template(template, **tpl_vars)
|
||||||
write_file(app_file_path, content, overwrite=False, dry_run=dry_run)
|
write_file(app_file_path, content, overwrite=False, dry=dry)
|
||||||
|
|
||||||
# === Handle __main__.py ===
|
# === Handle __main__.py ===
|
||||||
main_file = module_dir / "__main__.py"
|
main_file = module_dir / "__main__.py"
|
||||||
@@ -1052,7 +1351,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
main_file,
|
main_file,
|
||||||
main_content,
|
main_content,
|
||||||
overwrite=True,
|
overwrite=True,
|
||||||
dry_run=dry_run,
|
dry=dry,
|
||||||
fallback_path=main_fallback,
|
fallback_path=main_fallback,
|
||||||
)
|
)
|
||||||
elif not existing_cli:
|
elif not existing_cli:
|
||||||
@@ -1061,33 +1360,37 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
main_file,
|
main_file,
|
||||||
main_content,
|
main_content,
|
||||||
overwrite=False,
|
overwrite=False,
|
||||||
dry_run=dry_run,
|
dry=dry,
|
||||||
)
|
)
|
||||||
# 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"
|
||||||
if frontend_dir.exists():
|
if frontend_dir.exists():
|
||||||
# Install the vite plugin file (always update)
|
# Install the vite plugin file (always update)
|
||||||
plugin_file = frontend_dir / "vite-plugin-fastapi.js"
|
plugin_file = frontend_dir / "vite-plugin-fastapi.js"
|
||||||
|
# Upgrade old plugin versions that lack the auto-upgrade marker
|
||||||
|
_upgrade_old_vite_plugin(plugin_file, module_name, dry)
|
||||||
template = load_template("frontend/vite-plugin-fastapi.js")
|
template = load_template("frontend/vite-plugin-fastapi.js")
|
||||||
content = render_template(template, **tpl_vars)
|
content = render_template(template, **tpl_vars)
|
||||||
write_file(plugin_file, content, overwrite=True, dry_run=dry_run)
|
write_file(plugin_file, content, overwrite=True, dry=dry)
|
||||||
|
|
||||||
# Find existing vite config (prefer .ts, fall back to .js)
|
# Find existing vite config (prefer .ts, fall back to .js)
|
||||||
vite_config_ts = frontend_dir / "vite.config.ts"
|
vite_config_ts = frontend_dir / "vite.config.ts"
|
||||||
vite_config_js = frontend_dir / "vite.config.js"
|
vite_config_js = frontend_dir / "vite.config.js"
|
||||||
|
|
||||||
if vite_config_ts.exists():
|
if vite_config_ts.exists():
|
||||||
patch_vite_config(vite_config_ts, module_name, dry_run)
|
patch_vite_config(vite_config_ts, module_name, dry)
|
||||||
elif vite_config_js.exists():
|
elif vite_config_js.exists():
|
||||||
patch_vite_config(vite_config_js, module_name, dry_run)
|
patch_vite_config(vite_config_js, module_name, dry)
|
||||||
else:
|
else:
|
||||||
print("⚠️ No vite.config.ts or vite.config.js found in frontend/")
|
print("⚠️ No vite.config.ts or vite.config.js found in frontend/")
|
||||||
print(" Run create-vue first to generate a Vite config to patch.")
|
print(" Run create-vue first to generate a Vite config to patch.")
|
||||||
|
|
||||||
# Patch Vue app with backend health check
|
# Patch Vue app with backend health check
|
||||||
patch_frontend_health_check(frontend_dir, dry_run)
|
patch_frontend_health_check(frontend_dir, dry)
|
||||||
|
|
||||||
# === Update pyproject.toml ===
|
# === Update pyproject.toml ===
|
||||||
pyproject_path = project_dir / "pyproject.toml"
|
pyproject_path = project_dir / "pyproject.toml"
|
||||||
@@ -1108,8 +1411,8 @@ 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:
|
||||||
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}")
|
||||||
@@ -1121,8 +1424,8 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
gitignore_content = gitignore_path.read_bytes()
|
gitignore_content = gitignore_path.read_bytes()
|
||||||
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:
|
||||||
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
|
||||||
@@ -1130,34 +1433,26 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
gitignore_content + suffix + gitignore_entry.encode() + nl
|
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:
|
||||||
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 = ["uv", "add", "-q", "-U", "--no-sync", "fastapi[standard]", "fastapi-vue"]
|
ruff_sort_imports(_python_files_to_format, dry=dry)
|
||||||
uv_add_dev = ["uv", "add", "-q", "-U", "--group", "dev", "httpx"]
|
if dry:
|
||||||
if dry_run:
|
print("📦 Would add: fastapi[standard], fastapi-vue, httpx (dev only)")
|
||||||
print(f"[DRY RUN] Would run: {' '.join(uv_add_main)}")
|
|
||||||
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_boxed("Setup complete!")
|
||||||
print("✅ Setup complete!")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Show cd command only if project is not in current directory
|
# Show cd command only if project is not in current directory
|
||||||
cd_cmd = "" if project_dir == Path.cwd() else f"cd {project_dir}\n "
|
cd_cmd = "" if project_dir == Path.cwd() else f"cd {project_dir}; "
|
||||||
script_name = module_name.replace("_", "-")
|
script_name = module_name.replace("_", "-")
|
||||||
|
|
||||||
message = SETUP_COMPLETE_MESSAGE.replace("CD_CMD", cd_cmd).replace(
|
message = SETUP_COMPLETE_MESSAGE.replace("CD_CMD", cd_cmd).replace(
|
||||||
@@ -1181,11 +1476,6 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Main entry point
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def is_uninitialized_folder(path: Path) -> bool:
|
def is_uninitialized_folder(path: Path) -> bool:
|
||||||
"""Check if a folder appears to be completely uninitialized."""
|
"""Check if a folder appears to be completely uninitialized."""
|
||||||
return (
|
return (
|
||||||
@@ -1208,13 +1498,14 @@ def is_already_patched(path: Path) -> bool:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Set up FastAPI+Vue projects with integrated build/dev systems",
|
description=f"fastapi-vue-setup {version} - FastAPI + Vue project setup tool",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog="""
|
epilog="""
|
||||||
Examples:
|
Examples:
|
||||||
fastapi-vue-setup my-new-project Create a new project from scratch
|
fastapi-vue-setup my-new-project Create a new project from scratch
|
||||||
fastapi-vue-setup . Set up integration in current directory
|
fastapi-vue-setup . Set up integration in current directory
|
||||||
fastapi-vue-setup . --dry-run Preview what would be done
|
fastapi-vue-setup . --dry Preview what would be done
|
||||||
|
fastapi-vue-setup . --ports=8000,5173,8080 Change default ports (backend, vite dev, backend dev)
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -1223,9 +1514,15 @@ Examples:
|
|||||||
default=None,
|
default=None,
|
||||||
help="Project directory (use . for current directory)",
|
help="Project directory (use . for current directory)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument("--version", action="version", version=version)
|
||||||
parser.add_argument("--module-name", help="Python module name (auto-detected)")
|
parser.add_argument("--module-name", help="Python module name (auto-detected)")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dry-run", action="store_true", help="Show what would be done"
|
"--ports",
|
||||||
|
metavar="BACKEND,VITE,DEV",
|
||||||
|
help="Port configuration as comma-separated values (default: 3100,3100,3200)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry", "--dry-run", action="store_true", help="Show what would be done"
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|||||||
+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 }
|
||||||
|
|||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env -S uv run
|
||||||
|
"""Build and release both fastapi-vue and fastapi-vue-setup packages."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DIST = ROOT / "dist"
|
||||||
|
FASTAPI_VUE = ROOT / "fastapi-vue"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Clear the dist directory
|
||||||
|
if DIST.exists():
|
||||||
|
shutil.rmtree(DIST)
|
||||||
|
DIST.mkdir()
|
||||||
|
|
||||||
|
# Build fastapi-vue (subdirectory) to root dist
|
||||||
|
subprocess.run(
|
||||||
|
["uv", "build", "--out-dir", str(DIST)],
|
||||||
|
cwd=FASTAPI_VUE,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build fastapi-vue-setup (root)
|
||||||
|
subprocess.run(
|
||||||
|
["uv", "build", "--out-dir", str(DIST)],
|
||||||
|
cwd=ROOT,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,44 +1,29 @@
|
|||||||
# 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 asyncio
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue import server
|
||||||
from uvicorn import Config, Server
|
|
||||||
|
|
||||||
DEFAULT_PORT = 5080
|
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
|
||||||
|
DEVMODE = bool(os.getenv("ENVPREFIX_DEV") == "1")
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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())
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.")
|
parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"endpoint",
|
"-l",
|
||||||
nargs="?",
|
"--listen",
|
||||||
help=(
|
action="append",
|
||||||
f"Endpoint (default: localhost:{DEFAULT_PORT}). "
|
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
||||||
"Forms: host:port | :port | [ipv6]:port | ip | host | unix:/path.sock"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
dev = {"reload": True, "reload_dirs": ["MODULE_NAME"]}
|
||||||
try:
|
server.run(
|
||||||
run_server(parse_endpoint(args.endpoint, DEFAULT_PORT), proxy=proxy)
|
"MODULE_NAME.APP_MODULE:APP_VAR",
|
||||||
except KeyboardInterrupt:
|
listen=args.listen,
|
||||||
pass
|
default_port=DEFAULT_PORT,
|
||||||
|
**(dev if DEVMODE else {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -1,56 +1,51 @@
|
|||||||
#!/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 Vue app and FastAPI backend with auto-reload."""
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
# 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
|
||||||
ProcessGroup,
|
ProcessGroup,
|
||||||
|
check_ports_free,
|
||||||
logger,
|
logger,
|
||||||
ready,
|
ready,
|
||||||
setup_fastapi,
|
setup_cli,
|
||||||
setup_vite,
|
setup_vite,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DEFAULT_VITE_PORT = TEMPLATE_VITE_PORT
|
||||||
|
DEFAULT_DEV_PORT = TEMPLATE_DEV_PORT
|
||||||
|
|
||||||
async def run_devserver(frontend: str, backend: str) -> None:
|
|
||||||
|
async def run_devserver(
|
||||||
|
listen: 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():
|
||||||
logger.warning("Frontend source not found at %s", front)
|
logger.warning("Frontend source not found at %s", front)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
frontend_url, npm_install, vite = setup_vite(frontend)
|
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
|
||||||
backend_url, fastapi = setup_fastapi(backend, "MODULE_NAME.APP_MODULE:APP_VAR")
|
backurl, MODULE_NAME = setup_cli("PROJECT_CLI", backend, DEFAULT_DEV_PORT)
|
||||||
|
|
||||||
# Tell the everyone where the frontend and backend are (vite proxy, etc)
|
# Tell the everyone by environment (vite proxy and backend devmode use these)
|
||||||
os.environ["FASTAPI_VUE_FRONTEND_URL"] = frontend_url
|
os.environ["ENVPREFIX_VITE_URL"] = viteurl
|
||||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
os.environ["ENVPREFIX_BACKEND_URL"] = backurl
|
||||||
|
os.environ["ENVPREFIX_DEV"] = "1"
|
||||||
|
|
||||||
async with ProcessGroup() as pg:
|
async with ProcessGroup() as pg:
|
||||||
install_proc = await pg.spawn(*npm_install, cwd=front)
|
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||||
await asyncio.sleep(0.2) # reduce message overlap
|
await check_ports_free(viteurl, backurl)
|
||||||
await pg.spawn(
|
await pg.spawn(*MODULE_NAME, *(extra_args or []))
|
||||||
*fastapi,
|
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
|
||||||
"--reload",
|
|
||||||
"--reload-dir=MODULE_NAME", # Don't reload on frontend changes
|
|
||||||
"--forwarded-allow-ips=*",
|
|
||||||
cwd=reporoot,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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 Vite dev server (ProcessGroup waits for any exit, then terminates others)
|
|
||||||
await pg.spawn(*vite, cwd=front)
|
await pg.spawn(*vite, cwd=front)
|
||||||
|
|
||||||
|
|
||||||
@@ -61,27 +56,25 @@ def main():
|
|||||||
epilog=HELP_EPILOG,
|
epilog=HELP_EPILOG,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"frontend",
|
"-l", "--listen",
|
||||||
nargs="?",
|
|
||||||
metavar="host:port",
|
metavar="host:port",
|
||||||
help="Vite frontend endpoint (default: localhost:5173)",
|
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
metavar="host:port",
|
metavar="host:port",
|
||||||
help="FastAPI backend endpoint (default: localhost:5180)",
|
help=f"FastAPI (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.listen, 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).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Utilities used at build time and in devserver script. No dependencies."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class _PrefixFormatter(logging.Formatter):
|
||||||
|
"""Formatter that adds prefix based on log level."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
if record.levelno >= logging.WARNING:
|
||||||
|
return f"⚠️ {record.getMessage()}"
|
||||||
|
return record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
_handler = logging.StreamHandler()
|
||||||
|
_handler.setFormatter(_PrefixFormatter())
|
||||||
|
logger = logging.getLogger("fastapi-vue")
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_node_version(node_path: str) -> None:
|
||||||
|
"""Check if Node.js version is >= 20.
|
||||||
|
|
||||||
|
Raises RuntimeError if version is too old or cannot be determined.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[node_path, "--version"], capture_output=True, text=True, check=True
|
||||||
|
)
|
||||||
|
version_str = result.stdout.strip()
|
||||||
|
# Parse version like "v20.10.0" or "v18.17.1"
|
||||||
|
match = re.match(r"v(\d+)", version_str)
|
||||||
|
if match:
|
||||||
|
major_version = int(match.group(1))
|
||||||
|
if major_version >= 20:
|
||||||
|
return
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
||||||
|
)
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||||
|
pass
|
||||||
|
raise RuntimeError("Could not determine Node.js version")
|
||||||
|
|
||||||
|
|
||||||
|
def find_js_runtime() -> tuple[str, str]:
|
||||||
|
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||||
|
|
||||||
|
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||||
|
Raises JSRuntimeError if no suitable runtime is found.
|
||||||
|
"""
|
||||||
|
options = ["npm", "deno", "bun"]
|
||||||
|
node_version_error: RuntimeError | None = None
|
||||||
|
|
||||||
|
# Check for JS_RUNTIME environment variable
|
||||||
|
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
||||||
|
js_runtime = js_runtime_env
|
||||||
|
js_path = Path(js_runtime)
|
||||||
|
runtime_name = js_path.name
|
||||||
|
# Map node to npm
|
||||||
|
if runtime_name == "node":
|
||||||
|
runtime_name = "npm"
|
||||||
|
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||||
|
for option in options:
|
||||||
|
if option == runtime_name or runtime_name.startswith(option):
|
||||||
|
tool = shutil.which(js_runtime)
|
||||||
|
if tool is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||||
|
)
|
||||||
|
# Check Node.js version if using npm
|
||||||
|
if option == "npm":
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||||
|
)
|
||||||
|
_check_node_version(node_path) # Raises on failure
|
||||||
|
return tool, option
|
||||||
|
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
|
||||||
|
|
||||||
|
# Auto-detect
|
||||||
|
for option in options:
|
||||||
|
if tool := shutil.which(option):
|
||||||
|
# Check Node.js version if using npm
|
||||||
|
if option == "npm":
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_check_node_version(node_path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
node_version_error = e
|
||||||
|
continue # Try next runtime
|
||||||
|
return tool, option
|
||||||
|
|
||||||
|
# No runtime found - provide helpful error
|
||||||
|
if node_version_error:
|
||||||
|
raise node_version_error
|
||||||
|
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
|
||||||
|
|
||||||
|
|
||||||
|
def find_build_tool():
|
||||||
|
"""Find JavaScript runtime and construct install/build commands.
|
||||||
|
|
||||||
|
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||||
|
Raises RuntimeError if no runtime is found.
|
||||||
|
"""
|
||||||
|
install = {
|
||||||
|
"deno": ("install", "--allow-scripts=npm:vue-demi"),
|
||||||
|
"npm": ("install",),
|
||||||
|
"bun": ("--bun", "install"),
|
||||||
|
}
|
||||||
|
# Run vite directly for deno to avoid npm-run-all2/run-p issues
|
||||||
|
build = {
|
||||||
|
"deno": ("run", "-A", "npm:vite", "build"),
|
||||||
|
"npm": ("run", "build"),
|
||||||
|
"bun": ("--bun", "run", "build"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, name = find_js_runtime()
|
||||||
|
return [tool, *install[name]], [tool, *build[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def find_dev_tool() -> list[str]:
|
||||||
|
"""Find JavaScript runtime and construct dev command.
|
||||||
|
|
||||||
|
Returns dev_cmd (without vite-specific args).
|
||||||
|
Raises RuntimeError if no runtime is found.
|
||||||
|
"""
|
||||||
|
dev_args = {
|
||||||
|
"deno": ("run", "-A", "npm:vite"),
|
||||||
|
"npm": ("--silent", "run", "dev", "--"),
|
||||||
|
"bun": ("run", "dev", "--"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, name = find_js_runtime()
|
||||||
|
|
||||||
|
if name == "bun":
|
||||||
|
logger.warning(
|
||||||
|
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
return [tool, *dev_args[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def find_install_tool() -> list[str]:
|
||||||
|
"""Find JavaScript runtime and construct install command.
|
||||||
|
|
||||||
|
Returns install_cmd.
|
||||||
|
Raises RuntimeError if no runtime is found.
|
||||||
|
"""
|
||||||
|
install_args = {
|
||||||
|
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||||
|
"npm": ("install", "--silent"),
|
||||||
|
"bun": ("install", "--silent"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, name = find_js_runtime()
|
||||||
|
return [tool, *install_args[name]]
|
||||||
|
|
||||||
|
|
||||||
|
def build(folder: str = "frontend") -> None:
|
||||||
|
"""Build the frontend in the specified folder.
|
||||||
|
|
||||||
|
Raises SystemExit(1) on failure.
|
||||||
|
"""
|
||||||
|
logger.info(">>> Building %s", folder)
|
||||||
|
|
||||||
|
try:
|
||||||
|
install_cmd, build_cmd = find_build_tool()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning(e)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||||
|
logger.info("### %s", " ".join(display_cmd))
|
||||||
|
subprocess.run(cmd, check=True, cwd=folder)
|
||||||
|
|
||||||
|
try:
|
||||||
|
run(install_cmd)
|
||||||
|
logger.info("")
|
||||||
|
run(build_cmd)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Coroutine
|
||||||
|
from contextlib import suppress
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from buildutil import find_dev_tool, find_install_tool, logger
|
||||||
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessGroup:
|
||||||
|
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._procs: list[asyncio.subprocess.Process] = []
|
||||||
|
self._cmds: dict[int, str] = {} # pid -> command name
|
||||||
|
|
||||||
|
async def spawn(
|
||||||
|
self, *cmd: str, cwd: str | None = None
|
||||||
|
) -> asyncio.subprocess.Process:
|
||||||
|
"""Spawn a subprocess and track it."""
|
||||||
|
cmd_name = Path(cmd[0]).stem
|
||||||
|
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||||
|
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||||
|
self._procs.append(proc)
|
||||||
|
self._cmds[proc.pid] = cmd_name
|
||||||
|
return proc
|
||||||
|
|
||||||
|
async def wait(
|
||||||
|
self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]"
|
||||||
|
) -> None:
|
||||||
|
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||||
|
|
||||||
|
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
||||||
|
returncode = await proc.wait()
|
||||||
|
if returncode != 0:
|
||||||
|
cmd_name = self._cmds.get(proc.pid, "unknown")
|
||||||
|
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
||||||
|
for w in waitables
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||||
|
raise SystemExit(1) from None
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, *_):
|
||||||
|
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||||
|
await self._cleanup(immediate=exc_type is not None)
|
||||||
|
|
||||||
|
async def _cleanup(self, immediate: bool = False):
|
||||||
|
running = [p for p in self._procs if p.returncode is None]
|
||||||
|
if not running:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not immediate:
|
||||||
|
# Wait for any one process to exit
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await asyncio.wait(
|
||||||
|
[asyncio.create_task(p.wait()) for p in running],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Terminate remaining processes
|
||||||
|
for p in self._procs:
|
||||||
|
if p.returncode is None:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
p.terminate()
|
||||||
|
|
||||||
|
# Wait for all to finish (with overall timeout), shielded from cancellation
|
||||||
|
still_running = [p for p in self._procs if p.returncode is None]
|
||||||
|
if still_running:
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
try:
|
||||||
|
await asyncio.shield(
|
||||||
|
asyncio.wait_for(
|
||||||
|
asyncio.gather(*[p.wait() for p in still_running]),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
for p in self._procs:
|
||||||
|
if p.returncode is None:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
p.kill()
|
||||||
|
await p.wait()
|
||||||
|
|
||||||
|
|
||||||
|
async def check_ports_free(*urls: str) -> None:
|
||||||
|
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||||
|
|
||||||
|
async def check(client: httpx.AsyncClient, url: str) -> None:
|
||||||
|
with suppress(httpx.RequestError):
|
||||||
|
res = await client.get(url, timeout=0.1)
|
||||||
|
server = res.headers.get("server", "server")
|
||||||
|
logger.warning("Conflicting %s already running at %s", server, url)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
await asyncio.gather(*[check(client, url) for url in urls])
|
||||||
|
|
||||||
|
|
||||||
|
async def ready(url: str, path: str = "") -> None:
|
||||||
|
"""Wait for the server to be ready by polling an endpoint.
|
||||||
|
|
||||||
|
Raises SystemExit(1) if server doesn't start in time.
|
||||||
|
"""
|
||||||
|
max_attempts = 50
|
||||||
|
full_url = f"{url}{path}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
await client.get(full_url, timeout=1.0)
|
||||||
|
logger.info("✓ Backend ready!")
|
||||||
|
return
|
||||||
|
except httpx.RequestError:
|
||||||
|
if attempt == max_attempts - 1:
|
||||||
|
logger.warning("Backend didn't start in time")
|
||||||
|
raise SystemExit(1)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_vite(
|
||||||
|
endpoint: str, default_port: int = 5173
|
||||||
|
) -> tuple[str, list[str], list[str]]:
|
||||||
|
"""Parse frontend endpoint and build commands.
|
||||||
|
|
||||||
|
Returns (url, install_cmd, dev_cmd).
|
||||||
|
Raises SystemExit(1) on invalid config.
|
||||||
|
"""
|
||||||
|
endpoints = parse_endpoint(endpoint, default_port)
|
||||||
|
|
||||||
|
if "uds" in endpoints[0]:
|
||||||
|
logger.warning("Unix sockets not supported with vite devserver")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
port = endpoints[0]["port"]
|
||||||
|
host = endpoints[0]["host"]
|
||||||
|
|
||||||
|
install_cmd = find_install_tool()
|
||||||
|
dev_cmd = find_dev_tool()
|
||||||
|
if host != "localhost":
|
||||||
|
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
|
||||||
|
dev_cmd.append(f"--port={port}")
|
||||||
|
|
||||||
|
return f"http://{host}:{port}", install_cmd, dev_cmd
|
||||||
|
|
||||||
|
|
||||||
|
def setup_fastapi(
|
||||||
|
endpoint: str, module: str, default_port: int = 8000
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
|
"""Parse backend endpoint and build uvicorn command.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
endpoints = parse_endpoint(endpoint, default_port)
|
||||||
|
|
||||||
|
if "uds" in endpoints[0]:
|
||||||
|
logger.warning("Unix sockets not supported with vite devserver")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
host = endpoints[0]["host"]
|
||||||
|
port = endpoints[0]["port"]
|
||||||
|
|
||||||
|
cmd = [cli, f"--listen={host}:{port}"]
|
||||||
|
return f"http://{host}:{port}", cmd
|
||||||
Reference in New Issue
Block a user