Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcaf415032 | ||
|
|
79316eebd4 | ||
|
|
682d70cb23 | ||
|
|
1aaf040db7 | ||
|
|
aac27da67c | ||
|
|
79e645a263 | ||
|
|
8b8a24a6b2 | ||
|
|
cb6017cedf | ||
|
|
fab4108e92 | ||
|
|
637f737d4c | ||
|
|
0b986717eb | ||
|
|
9469d57f90 | ||
|
|
1ce0fffe3e | ||
|
|
f483cf978c | ||
|
|
0e12e3a531 | ||
|
|
67bea9a2a7 | ||
|
|
a05c8236f6 |
@@ -84,7 +84,7 @@ my-app/
|
|||||||
└── scripts/
|
└── scripts/
|
||||||
├── devserver.py # Run Vite and FastAPI together in dev mode
|
├── devserver.py # Run Vite and FastAPI together in dev mode
|
||||||
└── fastapi-vue/ # Dev utilities (only on the source tree)
|
└── fastapi-vue/ # Dev utilities (only on the source tree)
|
||||||
├── build-frontend.py
|
├── buildhook.py
|
||||||
├── buildutil.py
|
├── buildutil.py
|
||||||
└── devutil.py
|
└── devutil.py
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""FastAPI Vue integration - serve Vue frontend from FastAPI."""
|
||||||
|
|
||||||
from .staticfiles import Frontend
|
from .staticfiles import Frontend
|
||||||
|
|
||||||
__all__ = ["Frontend"]
|
__all__ = ["Frontend"]
|
||||||
|
|||||||
@@ -1,8 +1,60 @@
|
|||||||
|
"""Parse endpoint strings for uvicorn server configuration."""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_all_interfaces(value: str) -> list[dict] | None:
|
||||||
|
"""Parse ':port' format to bind all interfaces."""
|
||||||
|
if not (value.startswith(":") and value != ":"):
|
||||||
|
return None
|
||||||
|
port_part = value[1:]
|
||||||
|
if not port_part.isdigit():
|
||||||
|
msg = f"Invalid port in '{value}'"
|
||||||
|
raise SystemExit(msg)
|
||||||
|
port = int(port_part)
|
||||||
|
return [{"host": "0.0.0.0", "port": port}, {"host": "::", "port": port}] # noqa: S104
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_unix_socket(value: str) -> list[dict] | None:
|
||||||
|
"""Parse UNIX domain socket paths."""
|
||||||
|
if value.startswith("/"):
|
||||||
|
return [{"uds": value}]
|
||||||
|
if value.startswith("unix:"):
|
||||||
|
uds_path = value[5:] or None
|
||||||
|
if uds_path is None:
|
||||||
|
msg = "unix: path must not be empty"
|
||||||
|
raise SystemExit(msg)
|
||||||
|
return [{"uds": uds_path}]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_unbracketed_ipv6(value: str, default_port: int) -> list[dict] | None:
|
||||||
|
"""Parse unbracketed IPv6 addresses."""
|
||||||
|
if value.count(":") <= 1 or value.startswith("["):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ipaddress.IPv6Address(value)
|
||||||
|
except ValueError as e:
|
||||||
|
msg = f"Invalid IPv6 address '{value}': {e}"
|
||||||
|
raise SystemExit(msg) from e
|
||||||
|
return [{"host": value, "port": default_port}]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_host_port(value: str, default_port: int) -> list[dict]:
|
||||||
|
"""Parse host[:port] or [ipv6][:port] using urlparse."""
|
||||||
|
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}]
|
||||||
|
|
||||||
|
|
||||||
def parse_endpoint(value: str | None, default_port: int = 0) -> list[dict]:
|
def parse_endpoint(value: str | None, default_port: int = 0) -> list[dict]:
|
||||||
"""Parse an endpoint string into uvicorn bind configurations.
|
"""Parse an endpoint string into uvicorn bind configurations.
|
||||||
|
|
||||||
@@ -12,6 +64,7 @@ def parse_endpoint(value: str | None, default_port: int = 0) -> list[dict]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of dicts with uvicorn bind kwargs (host/port or uds).
|
List of dicts with uvicorn bind kwargs (host/port or uds).
|
||||||
|
Two entries may be returned for IPv4 and IPv6 (all interaces).
|
||||||
|
|
||||||
Supported forms:
|
Supported forms:
|
||||||
- None or empty -> [{host: "localhost", port: default_port}]
|
- None or empty -> [{host: "localhost", port: default_port}]
|
||||||
@@ -22,6 +75,7 @@ def parse_endpoint(value: str | None, default_port: int = 0) -> list[dict]:
|
|||||||
- [ipv6]:port -> [{host: ipv6, port}]
|
- [ipv6]:port -> [{host: ipv6, port}]
|
||||||
- ipv6 (unbracketed) -> [{host: ipv6, port: default_port}]
|
- ipv6 (unbracketed) -> [{host: ipv6, port: default_port}]
|
||||||
- /path or unix:/path -> [{uds: path}]
|
- /path or unix:/path -> [{uds: path}]
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not value:
|
if not value:
|
||||||
return [{"host": "localhost", "port": default_port}]
|
return [{"host": "localhost", "port": default_port}]
|
||||||
@@ -30,38 +84,36 @@ def parse_endpoint(value: str | None, default_port: int = 0) -> list[dict]:
|
|||||||
if value.isdigit():
|
if value.isdigit():
|
||||||
return [{"host": "localhost", "port": int(value)}]
|
return [{"host": "localhost", "port": int(value)}]
|
||||||
|
|
||||||
# Leading colon :port -> bind all interfaces (0.0.0.0 + ::)
|
# Try specialized parsers in order
|
||||||
if value.startswith(":") and value != ":":
|
result = _parse_all_interfaces(value)
|
||||||
port_part = value[1:]
|
if result is not None:
|
||||||
if not port_part.isdigit():
|
return result
|
||||||
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)
|
result = _parse_unix_socket(value)
|
||||||
if value.startswith("/"):
|
if result is not None:
|
||||||
return [{"uds": value}]
|
return result
|
||||||
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
|
result = _parse_unbracketed_ipv6(value, default_port)
|
||||||
if value.count(":") > 1 and not value.startswith("["):
|
if result is not None:
|
||||||
try:
|
return result
|
||||||
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])
|
# Fallback: host[:port], [ipv6][:port]
|
||||||
parsed = urlparse(f"//{value}") # // prefix lets urlparse treat it as netloc
|
return _parse_host_port(value, default_port)
|
||||||
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}]
|
def parse_endpoints(
|
||||||
|
listen: str | list[str] | None = None,
|
||||||
|
default_port: int = 8000,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Parse listen strings into a list of endpoint dicts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
listen: Endpoint string(s) (see parse_endpoint for formats).
|
||||||
|
default_port: Port to use when not specified in listen args.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if listen is None:
|
||||||
|
listen = [f"localhost:{default_port}"]
|
||||||
|
elif isinstance(listen, str):
|
||||||
|
listen = [listen]
|
||||||
|
return [ep for s in listen for ep in parse_endpoint(s, default_port)]
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
"""Uvicorn server runner with multi-endpoint support."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from uvicorn import Config, Server
|
from uvicorn import Config, Server
|
||||||
|
|
||||||
from .hostutil import parse_endpoint
|
from .hostutil import parse_endpoints
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -18,8 +21,8 @@ def run(
|
|||||||
default_port: int = 8000,
|
default_port: int = 8000,
|
||||||
reload: bool = False,
|
reload: bool = False,
|
||||||
workers: int | None = None,
|
workers: int | None = None,
|
||||||
**uvicorn_config,
|
**uvicorn_config: Any, # noqa: ANN401
|
||||||
):
|
) -> None:
|
||||||
"""Run uvicorn server(s) for the given app.
|
"""Run uvicorn server(s) for the given app.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -29,14 +32,12 @@ def run(
|
|||||||
reload: Enable auto-reload (requires uvicorn.run, single endpoint only).
|
reload: Enable auto-reload (requires uvicorn.run, single endpoint only).
|
||||||
workers: Number of worker processes (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).
|
**uvicorn_config: Additional uvicorn config options (overrides all other settings).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if listen is None:
|
endpoints = parse_endpoints(listen, default_port)
|
||||||
listen = [f"localhost:{default_port}"]
|
if not endpoints:
|
||||||
elif isinstance(listen, str):
|
msg = "No endpoints to serve; check listen configuration"
|
||||||
listen = [listen]
|
raise ValueError(msg)
|
||||||
endpoints: list[dict] = []
|
|
||||||
for ep in listen:
|
|
||||||
endpoints.extend(parse_endpoint(ep, default_port))
|
|
||||||
|
|
||||||
conf: dict[str, object] = {"app": app, "reload": reload, "workers": workers}
|
conf: dict[str, object] = {"app": app, "reload": reload, "workers": workers}
|
||||||
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
||||||
@@ -45,14 +46,14 @@ def run(
|
|||||||
conf["forwarded_allow_ips"] = proxy
|
conf["forwarded_allow_ips"] = proxy
|
||||||
conf.update(uvicorn_config)
|
conf.update(uvicorn_config)
|
||||||
|
|
||||||
with suppress(KeyboardInterrupt):
|
with suppress(KeyboardInterrupt, asyncio.CancelledError):
|
||||||
if reload or workers:
|
if reload or workers:
|
||||||
serve_multiprocess(endpoints, **conf)
|
serve_multiprocess(endpoints, **conf)
|
||||||
else:
|
else:
|
||||||
asyncio.run(serve(endpoints, **conf))
|
asyncio.run(serve(endpoints, **conf))
|
||||||
|
|
||||||
|
|
||||||
async def serve(endpoints: list[dict], **kwargs) -> None:
|
async def serve(endpoints: list[dict], **kwargs: Any) -> None: # noqa: ANN401
|
||||||
"""Serve the given endpoints in current process/loop. Does not spawn extra processes."""
|
"""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}
|
forbidden = {"reload", "workers"} & {k for k, v in kwargs.items() if v}
|
||||||
if forbidden:
|
if forbidden:
|
||||||
@@ -63,13 +64,10 @@ async def serve(endpoints: list[dict], **kwargs) -> None:
|
|||||||
await asyncio.gather(*(Server(Config(**kwargs, **ep)).serve() for ep in endpoints))
|
await asyncio.gather(*(Server(Config(**kwargs, **ep)).serve() for ep in endpoints))
|
||||||
|
|
||||||
|
|
||||||
def serve_multiprocess(endpoints: list[dict], **kwargs) -> None:
|
def serve_multiprocess(endpoints: list[dict], **kwargs: Any) -> None: # noqa: ANN401
|
||||||
"""Serve using uvicorn.run() for reload/workers support. Only first endpoint is used."""
|
"""Serve using uvicorn.run() for reload/workers support. Only first endpoint is used."""
|
||||||
if len(endpoints) > 1:
|
if len(endpoints) > 1:
|
||||||
eps = [
|
eps = [ep["uds"] if "uds" in ep else f"{ep['host']}:{ep['port']}" for ep in endpoints]
|
||||||
ep["uds"] if "uds" in ep else f"{ep['host']}:{ep['port']}"
|
|
||||||
for ep in endpoints
|
|
||||||
]
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Current mode supports only one endpoint. Listening: %s, skipped: %s",
|
"Current mode supports only one endpoint. Listening: %s, skipped: %s",
|
||||||
eps[0],
|
eps[0],
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ __all__ = ["Frontend"]
|
|||||||
|
|
||||||
|
|
||||||
class Assets:
|
class Assets:
|
||||||
"""Default cached value to /assets/"""
|
"""Default cached value to /assets/."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse(cached: str | list[str] | Assets) -> list[str]:
|
def parse(cached: str | list[str] | Assets) -> list[str]:
|
||||||
@@ -37,7 +37,8 @@ class Assets:
|
|||||||
case list():
|
case list():
|
||||||
return cached
|
return cached
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(f"Invalid cached value: {cached!r}")
|
msg = f"Invalid cached value: {cached!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
|
||||||
class Frontend:
|
class Frontend:
|
||||||
@@ -55,27 +56,29 @@ class Frontend:
|
|||||||
index: Name of the index file (default: "index.html")
|
index: Name of the index file (default: "index.html")
|
||||||
spa: Enable SPA mode - serve index.html for unknown routes (default: False)
|
spa: Enable SPA mode - serve index.html for unknown routes (default: False)
|
||||||
cached: Path prefixes that are immutable (default: "/assets/")
|
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
|
favicon: Wildcard path to favicon. E.g. /assets/logo*.png matches Vite output
|
||||||
zstdlevel: Zstd compression level (default: 18)
|
zstdlevel: Zstd compression level (default: 18)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__( # noqa: PLR0913
|
||||||
self,
|
self,
|
||||||
directory: Path | str,
|
directory: Path | str,
|
||||||
*,
|
*,
|
||||||
index: str = "index.html",
|
index: str = "index.html",
|
||||||
spa: bool = False,
|
spa: bool = False,
|
||||||
catch_all: bool | None = None,
|
catch_all: bool | None = None,
|
||||||
cached: str | list[str] | Assets = Assets(),
|
cached: str | list[str] | Assets | None = None,
|
||||||
favicon: str | None = None,
|
favicon: str | None = None,
|
||||||
zstdlevel: int = 18,
|
zstdlevel: int = 18,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Initialize Frontend with given configuration."""
|
||||||
self.www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
self.www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
||||||
self.base: Path = Path(directory)
|
self.base: Path = Path(directory)
|
||||||
self.index = index
|
self.index = index
|
||||||
self.spa = spa
|
self.spa = spa
|
||||||
self._catch_all = spa if catch_all is None else catch_all
|
self._catch_all = spa if catch_all is None else catch_all
|
||||||
self.cached_paths = Assets.parse(cached)
|
self.cached_paths = Assets.parse(cached if cached is not None else Assets())
|
||||||
self.zstdlevel = zstdlevel
|
self.zstdlevel = zstdlevel
|
||||||
self.favicon = favicon
|
self.favicon = favicon
|
||||||
self._app: FastAPI | None = None
|
self._app: FastAPI | None = None
|
||||||
@@ -107,11 +110,12 @@ class Frontend:
|
|||||||
paths.add("/favicon.ico")
|
paths.add("/favicon.ico")
|
||||||
return paths
|
return paths
|
||||||
|
|
||||||
def _load(self):
|
def _load(self) -> dict[str, tuple[bytes, bytes | None, dict]]:
|
||||||
"""Load static files from disk with compression."""
|
"""Load static files from disk with compression."""
|
||||||
www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
||||||
if not self.base.exists():
|
if not self.base.exists():
|
||||||
raise ValueError(f"Frontend folder {self.base} not found (try uv build)")
|
msg = f"Frontend folder {self.base} not found (try uv build)"
|
||||||
|
raise ValueError(msg)
|
||||||
paths = [PurePath()]
|
paths = [PurePath()]
|
||||||
while paths:
|
while paths:
|
||||||
current = self.base / paths.pop(0)
|
current = self.base / paths.pop(0)
|
||||||
@@ -133,21 +137,18 @@ class Frontend:
|
|||||||
headers = {
|
headers = {
|
||||||
"etag": f'"{etag}"',
|
"etag": f'"{etag}"',
|
||||||
"last-modified": format_date_time(mtime),
|
"last-modified": format_date_time(mtime),
|
||||||
"cache-control": (
|
"cache-control": ("max-age=31536000, immutable" if cached else "no-cache"),
|
||||||
"max-age=31536000, immutable" if cached else "no-cache"
|
|
||||||
),
|
|
||||||
"content-type": mime,
|
"content-type": mime,
|
||||||
}
|
}
|
||||||
zstd = ZstdCompressor(self.zstdlevel).compress(data)
|
zstd = ZstdCompressor(self.zstdlevel).compress(data)
|
||||||
if len(zstd) >= len(data):
|
if len(zstd) >= len(data):
|
||||||
zstd = None
|
zstd = None
|
||||||
www[name] = data, zstd, headers
|
www[name] = data, zstd, headers
|
||||||
if self.favicon:
|
if self.favicon and (m := fnmatch.filter(www, self.favicon)):
|
||||||
if m := fnmatch.filter(www, self.favicon):
|
data, zstd, headers = www[m[0]]
|
||||||
data, zstd, headers = www[m[0]]
|
if "immutable" in headers.get("cache-control", ""):
|
||||||
if "immutable" in headers.get("cache-control", ""):
|
headers = {**headers, "cache-control": "max-age=86400"}
|
||||||
headers = {**headers, "cache-control": "max-age=86400"}
|
www["/favicon.ico"] = data, zstd, headers
|
||||||
www["/favicon.ico"] = data, zstd, headers
|
|
||||||
if not www:
|
if not www:
|
||||||
msg = "Frontend files missing, check your installation.\n"
|
msg = "Frontend files missing, check your installation.\n"
|
||||||
www["/"] = (
|
www["/"] = (
|
||||||
@@ -161,7 +162,7 @@ class Frontend:
|
|||||||
)
|
)
|
||||||
return www
|
return www
|
||||||
|
|
||||||
async def load(self, *, debug: bool | None = None, log: bool = True):
|
async def load(self, *, debug: bool | None = None, log: bool = True) -> None:
|
||||||
"""Load or reload static files from disk.
|
"""Load or reload static files from disk.
|
||||||
|
|
||||||
In debug mode, returns 409 instead of files (avoid accidental use of stale builds)
|
In debug mode, returns 409 instead of files (avoid accidental use of stale builds)
|
||||||
@@ -187,13 +188,19 @@ class Frontend:
|
|||||||
ratio = comp / raw * 100 if raw else 100.0
|
ratio = comp / raw * 100 if raw else 100.0
|
||||||
if log and self.www:
|
if log and self.www:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{self.base.name}: {len(self.www)} files in {1000 * duration:.1f} ms | "
|
"%s: %d files in %.1f ms | zstd %d files %.2f->%.2f MB (%.0f %%)",
|
||||||
f"zstd {len(compfiles)} files {1e-6 * raw:.2f}->{1e-6 * comp:.2f} MB ({ratio:.0f} %)"
|
self.base.name,
|
||||||
|
len(self.www),
|
||||||
|
1000 * duration,
|
||||||
|
len(compfiles),
|
||||||
|
1e-6 * raw,
|
||||||
|
1e-6 * comp,
|
||||||
|
ratio,
|
||||||
)
|
)
|
||||||
if self.favicon and "/favicon.ico" not in self.www:
|
if self.favicon and "/favicon.ico" not in self.www:
|
||||||
logger.warning("Favicon not found: %s", self.favicon)
|
logger.warning("Favicon not found: %s", self.favicon)
|
||||||
|
|
||||||
def route(self, app: FastAPI, mount_path="/"):
|
def route(self, app: FastAPI, mount_path: str = "/") -> None:
|
||||||
"""Register frontend routes with a FastAPI app.
|
"""Register frontend routes with a FastAPI app.
|
||||||
|
|
||||||
In SPA/catch-all mode, this must only be called only after all other routes.
|
In SPA/catch-all mode, this must only be called only after all other routes.
|
||||||
@@ -204,6 +211,7 @@ class Frontend:
|
|||||||
Args:
|
Args:
|
||||||
app: FastAPI application instance
|
app: FastAPI application instance
|
||||||
mount_path: Path where the frontend should be mounted (default: "/")
|
mount_path: Path where the frontend should be mounted (default: "/")
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self._app = app
|
self._app = app
|
||||||
self._mount_path = mount_path.rstrip("/")
|
self._mount_path = mount_path.rstrip("/")
|
||||||
@@ -212,9 +220,9 @@ class Frontend:
|
|||||||
if self._catch_all:
|
if self._catch_all:
|
||||||
# Register catch-all immediately (works without load)
|
# Register catch-all immediately (works without load)
|
||||||
path = self._mount_path + "{path:path}"
|
path = self._mount_path + "{path:path}"
|
||||||
app.api_route(path, methods=["GET", "HEAD"], name="frontend")(self.handle)
|
app.api_route(path, methods=["GET", "HEAD"], name="frontend", response_model=None)(self.handle)
|
||||||
|
|
||||||
def _register_routes(self):
|
def _register_routes(self) -> None:
|
||||||
"""Register individual routes for each loaded file (non-catch_all mode)."""
|
"""Register individual routes for each loaded file (non-catch_all mode)."""
|
||||||
if self._app is None or self._catch_all:
|
if self._app is None or self._catch_all:
|
||||||
return
|
return
|
||||||
@@ -240,18 +248,19 @@ class Frontend:
|
|||||||
for p in paths
|
for p in paths
|
||||||
]
|
]
|
||||||
|
|
||||||
def _respond(self, request: Request, name: str):
|
def _respond(self, request: Request, name: str) -> Response:
|
||||||
"""Serve a static file with ETag and compression support."""
|
"""Serve a static file with ETag and compression support."""
|
||||||
data, zstd, headers = self.www[name]
|
data, zstd, headers = self.www[name]
|
||||||
if request.headers.get("if-none-match") == headers["etag"]:
|
if request.headers.get("if-none-match") == headers["etag"]:
|
||||||
return Response(status_code=304, headers=headers)
|
return Response(status_code=304, headers=headers)
|
||||||
if zstd and "zstd" in request.headers.get("accept-encoding", ""):
|
if zstd and "zstd" in request.headers.get("accept-encoding", ""):
|
||||||
return Response(
|
return Response(
|
||||||
content=zstd, headers={**headers, "content-encoding": "zstd"}
|
content=zstd,
|
||||||
|
headers={**headers, "content-encoding": "zstd"},
|
||||||
)
|
)
|
||||||
return Response(content=data, headers=headers)
|
return Response(content=data, headers=headers)
|
||||||
|
|
||||||
def handle(self, request: Request, path: str):
|
def handle(self, request: Request, path: str) -> Response | RedirectResponse:
|
||||||
"""SPA catch-all handler with directory redirects and fallback to index."""
|
"""SPA catch-all handler with directory redirects and fallback to index."""
|
||||||
name = path.removesuffix(self.index)
|
name = path.removesuffix(self.index)
|
||||||
debug = getattr(self._app, "debug", False)
|
debug = getattr(self._app, "debug", False)
|
||||||
@@ -271,11 +280,9 @@ class Frontend:
|
|||||||
return (_devmode_respond if debug else self._respond)(request, name)
|
return (_devmode_respond if debug else self._respond)(request, name)
|
||||||
|
|
||||||
|
|
||||||
def _devmode_respond(request: Request, name=""):
|
def _devmode_respond(_request: Request, _name: str = "") -> JSONResponse:
|
||||||
"""Return error response directing to Vite server."""
|
"""Return error response directing to Vite server."""
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
content={
|
content={"detail": "[devmode] Use Vite devserver instead."},
|
||||||
"detail": "Frontend assets served by Vite in debug mode. You are on backend, connect to frontend instead."
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|||||||
+340
-185
File diff suppressed because it is too large
Load Diff
@@ -31,3 +31,20 @@ dev = ["ruff", "fastapi-vue"]
|
|||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
fastapi-vue = { path = "fastapi-vue", editable = true }
|
fastapi-vue = { path = "fastapi-vue", editable = true }
|
||||||
|
|
||||||
|
[tool.uv.workspace]
|
||||||
|
members = [
|
||||||
|
"f",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["ALL"]
|
||||||
|
ignore = ["D203", "D213", "COM812"] # Conflicting with D211, D212 and formatting
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"template/**" = ["F821"] # Undefined names are template placeholders
|
||||||
|
"template/scripts/devserver.py" = ["N806"] # MODULE_NAME is a template variable
|
||||||
|
"fastapi_vue_setup.py" = ["PLR", "C901", "T201", "RUF001"]
|
||||||
|
|||||||
+4
-11
@@ -10,25 +10,18 @@ DIST = ROOT / "dist"
|
|||||||
FASTAPI_VUE = ROOT / "fastapi-vue"
|
FASTAPI_VUE = ROOT / "fastapi-vue"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
|
"""Build both packages to dist directory."""
|
||||||
# Clear the dist directory
|
# Clear the dist directory
|
||||||
if DIST.exists():
|
if DIST.exists():
|
||||||
shutil.rmtree(DIST)
|
shutil.rmtree(DIST)
|
||||||
DIST.mkdir()
|
DIST.mkdir()
|
||||||
|
|
||||||
# Build fastapi-vue (subdirectory) to root dist
|
# Build fastapi-vue (subdirectory) to root dist
|
||||||
subprocess.run(
|
subprocess.run(["uv", "build", "--out-dir", str(DIST)], cwd=FASTAPI_VUE, check=True) # noqa: S603, S607
|
||||||
["uv", "build", "--out-dir", str(DIST)],
|
|
||||||
cwd=FASTAPI_VUE,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build fastapi-vue-setup (root)
|
# Build fastapi-vue-setup (root)
|
||||||
subprocess.run(
|
subprocess.run(["uv", "build", "--out-dir", str(DIST)], cwd=ROOT, check=True) # noqa: S603, S607
|
||||||
["uv", "build", "--out-dir", str(DIST)],
|
|
||||||
cwd=ROOT,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Backend package with FastAPI application and Vue frontend integration."""
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||||
|
"""Command-line entry point for running the backend server."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi_vue import server
|
from fastapi_vue import server
|
||||||
|
|
||||||
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
|
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
|
||||||
DEVMODE = bool(os.getenv("ENVPREFIX_DEV") == "1")
|
DEVMODE = os.getenv("ENVPREFIX_DEV") == "1"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
|
"""Run the backend server with optional arguments."""
|
||||||
parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.")
|
parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-l",
|
"-l",
|
||||||
@@ -17,12 +20,12 @@ def main():
|
|||||||
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
dev = {"reload": True, "reload_dirs": ["MODULE_NAME"]}
|
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
|
||||||
server.run(
|
server.run(
|
||||||
"MODULE_NAME.APP_MODULE:APP_VAR",
|
"APP_MODULE:APP_VAR",
|
||||||
listen=args.listen,
|
listen=args.listen,
|
||||||
default_port=DEFAULT_PORT,
|
default_port=DEFAULT_PORT,
|
||||||
**(dev if DEVMODE else {}),
|
**dev,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
|
"""FastAPI application module with Vue frontend integration."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
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
|
from MAIN_MODULE import DEVMODE
|
||||||
|
|
||||||
# Vue Frontend static files
|
# Vue Frontend static files
|
||||||
frontend = Frontend(Path(__file__).with_name("frontend-build"))
|
frontend = Frontend(Path(__file__).with_name("frontend-build"))
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||||
"""Manage app startup and shutdown resources."""
|
"""Manage app startup and shutdown resources."""
|
||||||
await frontend.load()
|
await frontend.load()
|
||||||
yield
|
yield
|
||||||
@@ -24,7 +27,8 @@ app = FastAPI(title="PROJECT_TITLE", debug=DEVMODE, lifespan=lifespan)
|
|||||||
|
|
||||||
# Health check endpoint for the Vue demo app to verify the backend is running
|
# Health check endpoint for the Vue demo app to verify the backend is running
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health_check():
|
async def health_check() -> dict[str, str]:
|
||||||
|
"""Return backend status for health monitoring."""
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||||
const backendUrl = process.env.ENVPREFIX_BACKEND_URL || "http://localhost:TEMPLATE_DEFAULT_PORT"
|
const backendUrl = process.env.ENVPREFIX_BACKEND_URL || "http://localhost:TEMPLATE_DEV_PORT"
|
||||||
|
|
||||||
// Build proxy configuration for each path
|
// Build proxy configuration for each path
|
||||||
const proxy = {}
|
const proxy = {}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ 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 (
|
||||||
ProcessGroup,
|
ProcessGroup,
|
||||||
check_ports_free,
|
check_ports_free,
|
||||||
logger,
|
logger,
|
||||||
@@ -22,11 +22,15 @@ from devutil import ( # type: ignore
|
|||||||
|
|
||||||
DEFAULT_VITE_PORT = TEMPLATE_VITE_PORT
|
DEFAULT_VITE_PORT = TEMPLATE_VITE_PORT
|
||||||
DEFAULT_DEV_PORT = TEMPLATE_DEV_PORT
|
DEFAULT_DEV_PORT = TEMPLATE_DEV_PORT
|
||||||
|
HEALTH = TEMPLATE_HEALTH
|
||||||
|
|
||||||
|
|
||||||
async def run_devserver(
|
async def run_devserver(
|
||||||
listen: str, backend: str, extra_args: list[str] | None = None
|
listen: str,
|
||||||
|
backend: str,
|
||||||
|
extra_args: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Start Vite and FastAPI dev servers with hot reload."""
|
||||||
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():
|
||||||
@@ -45,24 +49,26 @@ async def run_devserver(
|
|||||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||||
await check_ports_free(viteurl, backurl)
|
await check_ports_free(viteurl, backurl)
|
||||||
await pg.spawn(*MODULE_NAME, *(extra_args or []))
|
await pg.spawn(*MODULE_NAME, *(extra_args or []))
|
||||||
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
|
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
||||||
await pg.spawn(*vite, cwd=front)
|
await pg.spawn(*vite, cwd=front)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
|
"""Parse CLI arguments and run the devserver."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Run Vite and FastAPI development servers",
|
description="Run Vite and FastAPI development servers",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog=HELP_EPILOG,
|
epilog=HELP_EPILOG,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-l", "--listen",
|
"-l",
|
||||||
metavar="host:port",
|
"--listen",
|
||||||
|
metavar="addr",
|
||||||
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
metavar="host:port",
|
metavar="addr",
|
||||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||||
)
|
)
|
||||||
args, extra_args = parser.parse_known_args()
|
args, extra_args = parser.parse_known_args()
|
||||||
@@ -71,7 +77,7 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
scripts/devserver.py [args to PROJECT_CLI]
|
Other options are forwarded to PROJECT_CLI [args]
|
||||||
|
|
||||||
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).
|
npm, deno, bun, or full path to the runtime executable (node maps to npm).
|
||||||
|
|||||||
+7
-3
@@ -1,15 +1,19 @@
|
|||||||
|
# ruff: noqa: INP001
|
||||||
"""Hatch build hook for building Vue frontend during package build."""
|
"""Hatch build hook for building Vue frontend during package build."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
from buildutil import build
|
from buildutil import build
|
||||||
|
|
||||||
|
|
||||||
class CustomBuildHook(BuildHookInterface):
|
class CustomBuildHook(BuildHookInterface): # type: ignore[misc]
|
||||||
def initialize(self, version, build_data):
|
"""Hatch build hook that builds Vue frontend during package build."""
|
||||||
|
|
||||||
|
def initialize(self, version: str, build_data: dict) -> None: # type: ignore[override]
|
||||||
|
"""Build frontend before package is built."""
|
||||||
super().initialize(version, build_data)
|
super().initialize(version, build_data)
|
||||||
build("frontend")
|
build("frontend")
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# ruff: noqa: INP001
|
||||||
"""Utilities used at build time and in devserver script. No dependencies."""
|
"""Utilities used at build time and in devserver script. No dependencies."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -7,6 +8,8 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
MIN_NODE_VERSION = 20
|
||||||
|
|
||||||
|
|
||||||
class _PrefixFormatter(logging.Formatter):
|
class _PrefixFormatter(logging.Formatter):
|
||||||
"""Formatter that adds prefix based on log level."""
|
"""Formatter that adds prefix based on log level."""
|
||||||
@@ -30,82 +33,119 @@ def _check_node_version(node_path: str) -> None:
|
|||||||
Raises RuntimeError if version is too old or cannot be determined.
|
Raises RuntimeError if version is too old or cannot be determined.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run( # noqa: S603
|
||||||
[node_path, "--version"], capture_output=True, text=True, check=True
|
[node_path, "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
)
|
)
|
||||||
version_str = result.stdout.strip()
|
version_str = result.stdout.strip()
|
||||||
# Parse version like "v20.10.0" or "v18.17.1"
|
# Parse version like "v20.10.0" or "v18.17.1"
|
||||||
match = re.match(r"v(\d+)", version_str)
|
match = re.match(r"v(\d+)", version_str)
|
||||||
if match:
|
if match:
|
||||||
major_version = int(match.group(1))
|
major_version = int(match.group(1))
|
||||||
if major_version >= 20:
|
if major_version >= MIN_NODE_VERSION:
|
||||||
return
|
return
|
||||||
raise RuntimeError(
|
msg = f"Node.js {version_str} found, but v{MIN_NODE_VERSION}+ required"
|
||||||
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
raise RuntimeError(msg)
|
||||||
)
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||||
pass
|
pass
|
||||||
raise RuntimeError("Could not determine Node.js version")
|
msg = "Could not determine Node.js version"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_npm_runtime(tool: str) -> bool:
|
||||||
|
"""Validate npm runtime by checking Node.js version. Returns True if valid."""
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
_check_node_version(node_path)
|
||||||
|
except RuntimeError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _find_runtime_from_env(options: list[str]) -> tuple[str, str] | None:
|
||||||
|
"""Find runtime specified by JS_RUNTIME environment variable."""
|
||||||
|
js_runtime_env = os.environ.get("JS_RUNTIME")
|
||||||
|
if not js_runtime_env:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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 and not runtime_name.startswith(option):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tool = shutil.which(js_runtime)
|
||||||
|
if tool is None:
|
||||||
|
msg = f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
if option == "npm":
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
msg = f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
_check_node_version(node_path)
|
||||||
|
|
||||||
|
return tool, option
|
||||||
|
|
||||||
|
msg = f"JS_RUNTIME={js_runtime_env} not recognized"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_detect_runtime(options: list[str]) -> tuple[str, str]:
|
||||||
|
"""Auto-detect JavaScript runtime from available options."""
|
||||||
|
node_version_error: RuntimeError | None = None
|
||||||
|
|
||||||
|
for option in options:
|
||||||
|
tool = shutil.which(option)
|
||||||
|
if not tool:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if option == "npm" and not _validate_npm_runtime(tool):
|
||||||
|
try:
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path:
|
||||||
|
_check_node_version(node_path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
node_version_error = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
return tool, option
|
||||||
|
|
||||||
|
if node_version_error:
|
||||||
|
raise node_version_error
|
||||||
|
msg = "Node.js (v20+), Deno or Bun is required but none was found"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
def find_js_runtime() -> tuple[str, str]:
|
def find_js_runtime() -> tuple[str, str]:
|
||||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||||
|
|
||||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||||
Raises JSRuntimeError if no suitable runtime is found.
|
Raises RuntimeError if no suitable runtime is found.
|
||||||
"""
|
"""
|
||||||
options = ["npm", "deno", "bun"]
|
options = ["npm", "deno", "bun"]
|
||||||
node_version_error: RuntimeError | None = None
|
|
||||||
|
|
||||||
# Check for JS_RUNTIME environment variable
|
# Check for JS_RUNTIME environment variable
|
||||||
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
if result := _find_runtime_from_env(options):
|
||||||
js_runtime = js_runtime_env
|
return result
|
||||||
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
|
# Auto-detect
|
||||||
for option in options:
|
return _auto_detect_runtime(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():
|
def find_build_tool() -> tuple[list[str], list[str]]:
|
||||||
"""Find JavaScript runtime and construct install/build commands.
|
"""Find JavaScript runtime and construct install/build commands.
|
||||||
|
|
||||||
Returns (install_cmd, build_cmd) tuples of command lists.
|
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||||
@@ -143,7 +183,7 @@ def find_dev_tool() -> list[str]:
|
|||||||
|
|
||||||
if name == "bun":
|
if name == "bun":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
|
"Bun has a WS proxy bug (github.com/oven-sh/bun/issues/9882). Consider npm.",
|
||||||
)
|
)
|
||||||
|
|
||||||
return [tool, *dev_args[name]]
|
return [tool, *dev_args[name]]
|
||||||
@@ -176,16 +216,16 @@ def build(folder: str = "frontend") -> None:
|
|||||||
install_cmd, build_cmd = find_build_tool()
|
install_cmd, build_cmd = find_build_tool()
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1) from None
|
||||||
|
|
||||||
def run(cmd):
|
def run(cmd: list[str]) -> None:
|
||||||
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||||
logger.info("### %s", " ".join(display_cmd))
|
logger.info("### %s", " ".join(display_cmd))
|
||||||
subprocess.run(cmd, check=True, cwd=folder)
|
subprocess.run(cmd, check=True, cwd=folder) # noqa: S603
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run(install_cmd)
|
run(install_cmd)
|
||||||
logger.info("")
|
logger.info("")
|
||||||
run(build_cmd)
|
run(build_cmd)
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
raise SystemExit(1)
|
raise SystemExit(1) from None
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
|
# ruff: noqa: INP001
|
||||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Coroutine
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any, Self
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from buildutil import find_dev_tool, find_install_tool, logger
|
from buildutil import find_dev_tool, find_install_tool, logger
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Coroutine
|
||||||
|
|
||||||
|
|
||||||
class ProcessGroup:
|
class ProcessGroup:
|
||||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
|
"""Initialize empty process tracking."""
|
||||||
self._procs: list[asyncio.subprocess.Process] = []
|
self._procs: list[asyncio.subprocess.Process] = []
|
||||||
self._cmds: dict[int, str] = {} # pid -> command name
|
self._cmds: dict[int, str] = {} # pid -> command name
|
||||||
|
|
||||||
async def spawn(
|
async def spawn(
|
||||||
self, *cmd: str, cwd: str | None = None
|
self,
|
||||||
|
*cmd: str,
|
||||||
|
cwd: str | None = None,
|
||||||
) -> asyncio.subprocess.Process:
|
) -> asyncio.subprocess.Process:
|
||||||
"""Spawn a subprocess and track it."""
|
"""Spawn a subprocess and track it."""
|
||||||
cmd_name = Path(cmd[0]).stem
|
cmd_name = Path(cmd[0]).stem
|
||||||
@@ -32,7 +38,8 @@ class ProcessGroup:
|
|||||||
return proc
|
return proc
|
||||||
|
|
||||||
async def wait(
|
async def wait(
|
||||||
self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]"
|
self,
|
||||||
|
*waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||||
|
|
||||||
@@ -43,8 +50,7 @@ class ProcessGroup:
|
|||||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||||
|
|
||||||
tasks = [
|
tasks = [
|
||||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w for w in waitables
|
||||||
for w in waitables
|
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(*tasks)
|
await asyncio.gather(*tasks)
|
||||||
@@ -52,14 +58,15 @@ class ProcessGroup:
|
|||||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||||
raise SystemExit(1) from None
|
raise SystemExit(1) from None
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self) -> Self:
|
||||||
|
"""Enter the async context manager."""
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, *_):
|
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
||||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||||
await self._cleanup(immediate=exc_type is not None)
|
await self._cleanup(immediate=exc_type is not None)
|
||||||
|
|
||||||
async def _cleanup(self, immediate: bool = False):
|
async def _cleanup(self, *, immediate: bool = False) -> None:
|
||||||
running = [p for p in self._procs if p.returncode is None]
|
running = [p for p in self._procs if p.returncode is None]
|
||||||
if not running:
|
if not running:
|
||||||
return
|
return
|
||||||
@@ -87,7 +94,7 @@ class ProcessGroup:
|
|||||||
asyncio.wait_for(
|
asyncio.wait_for(
|
||||||
asyncio.gather(*[p.wait() for p in still_running]),
|
asyncio.gather(*[p.wait() for p in still_running]),
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
for p in self._procs:
|
for p in self._procs:
|
||||||
@@ -111,29 +118,32 @@ async def check_ports_free(*urls: str) -> None:
|
|||||||
await asyncio.gather(*[check(client, url) for url in urls])
|
await asyncio.gather(*[check(client, url) for url in urls])
|
||||||
|
|
||||||
|
|
||||||
async def ready(url: str, path: str = "") -> None:
|
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||||
"""Wait for the server to be ready by polling an endpoint.
|
"""Wait for the server to be ready by polling an endpoint.
|
||||||
|
|
||||||
|
Use empty path to disable the check and make this return immediately.
|
||||||
Raises SystemExit(1) if server doesn't start in time.
|
Raises SystemExit(1) if server doesn't start in time.
|
||||||
"""
|
"""
|
||||||
max_attempts = 50
|
if not path:
|
||||||
full_url = f"{url}{path}"
|
return
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
try:
|
try:
|
||||||
await client.get(full_url, timeout=1.0)
|
await client.get(f"{url}{path}", timeout=1.0)
|
||||||
logger.info("✓ Backend ready!")
|
|
||||||
return
|
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
if attempt == max_attempts - 1:
|
if attempt == max_attempts - 1:
|
||||||
logger.warning("Backend didn't start in time")
|
logger.warning("Backend didn't start in time")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1) from None
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
else:
|
||||||
|
logger.info("✓ Backend ready!")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def setup_vite(
|
def setup_vite(
|
||||||
endpoint: str, default_port: int = 5173
|
endpoint: str,
|
||||||
|
default_port: int = 5173,
|
||||||
) -> tuple[str, list[str], list[str]]:
|
) -> tuple[str, list[str], list[str]]:
|
||||||
"""Parse frontend endpoint and build commands.
|
"""Parse frontend endpoint and build commands.
|
||||||
|
|
||||||
@@ -159,7 +169,9 @@ def setup_vite(
|
|||||||
|
|
||||||
|
|
||||||
def setup_fastapi(
|
def setup_fastapi(
|
||||||
endpoint: str, module: str, default_port: int = 8000
|
endpoint: str,
|
||||||
|
module: str,
|
||||||
|
default_port: int = 8000,
|
||||||
) -> tuple[str, list[str]]:
|
) -> tuple[str, list[str]]:
|
||||||
"""Parse backend endpoint and build uvicorn command.
|
"""Parse backend endpoint and build uvicorn command.
|
||||||
|
|
||||||
@@ -174,7 +186,7 @@ def setup_fastapi(
|
|||||||
|
|
||||||
host = endpoints[0]["host"]
|
host = endpoints[0]["host"]
|
||||||
port = endpoints[0]["port"]
|
port = endpoints[0]["port"]
|
||||||
reload_dir = module.split(".")[0] # Don't reload on frontend changes
|
reload_dir = module.split(".", maxsplit=1)[0] # Don't reload on frontend changes
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
@@ -191,7 +203,9 @@ def setup_fastapi(
|
|||||||
|
|
||||||
|
|
||||||
def setup_cli(
|
def setup_cli(
|
||||||
cli: str, endpoint: str, default_port: int = 8000
|
cli: str,
|
||||||
|
endpoint: str,
|
||||||
|
default_port: int = 8000,
|
||||||
) -> tuple[str, list[str]]:
|
) -> tuple[str, list[str]]:
|
||||||
"""Parse backend endpoint and build CLI command.
|
"""Parse backend endpoint and build CLI command.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user