fastapi-vue-setup 1.4.1 upgrade, replaces our own access logging and more.
This commit is contained in:
@@ -5,13 +5,14 @@
|
|||||||
* 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
|
||||||
|
* - Disables Vite's screen clearing on startup
|
||||||
*
|
*
|
||||||
* Options:
|
* Options:
|
||||||
* paths - Array of paths to proxy (default: ["/api"])
|
* paths - Array of paths to proxy (default: ["/api"])
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||||
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8420"
|
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8421"
|
||||||
|
|
||||||
// Build proxy configuration for each path
|
// Build proxy configuration for each path
|
||||||
const proxy = {}
|
const proxy = {}
|
||||||
@@ -26,6 +27,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
|||||||
return {
|
return {
|
||||||
name: "vite-plugin-fastapi-mediahive",
|
name: "vite-plugin-fastapi-mediahive",
|
||||||
config: () => ({
|
config: () => ({
|
||||||
|
clearScreen: false,
|
||||||
server: { proxy },
|
server: { proxy },
|
||||||
build: {
|
build: {
|
||||||
outDir: "../mediahive/frontend-build",
|
outDir: "../mediahive/frontend-build",
|
||||||
|
|||||||
@@ -94,13 +94,13 @@ def main() -> None:
|
|||||||
roots[name] = p.as_posix()
|
roots[name] = p.as_posix()
|
||||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots)
|
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots)
|
||||||
|
|
||||||
dev = {"reload": True, "reload_dirs": ["mediahive"]}
|
|
||||||
server.run(
|
server.run(
|
||||||
"mediahive.server:app",
|
"mediahive.server:app",
|
||||||
listen=args.listen,
|
listen=args.listen,
|
||||||
default_port=DEFAULT_PORT,
|
default_port=DEFAULT_PORT,
|
||||||
|
server_header=False,
|
||||||
loop="none" if sys.platform == "win32" else "auto",
|
loop="none" if sys.platform == "win32" else "auto",
|
||||||
**(dev if DEVMODE and sys.platform != "win32" else {}),
|
reload=Path(__file__).parent if DEVMODE and sys.platform != "win32" else False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
"""Custom access logging middleware for FastAPI/Uvicorn."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from ipaddress import IPv6Address
|
|
||||||
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import Response
|
|
||||||
|
|
||||||
logger = logging.getLogger("mediahive.access")
|
|
||||||
|
|
||||||
_RESET = "\033[0m"
|
|
||||||
_STATUS_INFO = "\033[32m" # 1xx (green)
|
|
||||||
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
|
||||||
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
|
|
||||||
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
|
|
||||||
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
|
|
||||||
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
|
|
||||||
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
|
||||||
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
|
||||||
_PATH = "\033[38;5;250m" # path (white)
|
|
||||||
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
|
|
||||||
_WS_OPEN = "\033[38;5;226m" # WebSocket connect (brightest yellow from 6x6x6 cube)
|
|
||||||
_WS_CLOSE = "\033[38;5;142m" # WebSocket disconnect (significantly dimmer yellow)
|
|
||||||
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
|
|
||||||
|
|
||||||
|
|
||||||
def format_ipv6_network(ip: str) -> str:
|
|
||||||
"""Format IPv6 address to show only network part (first 64 bits).
|
|
||||||
|
|
||||||
Special addresses are returned as-is for clarity:
|
|
||||||
- ::1 (loopback)
|
|
||||||
- :: (unspecified)
|
|
||||||
- ::ffff:x.x.x.x (IPv4-mapped, returns just the IPv4 part)
|
|
||||||
- fe80:: (link-local, returned as-is since interface-specific)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Strip brackets that some proxies add around IPv6
|
|
||||||
ip = ip.strip("[]")
|
|
||||||
# Strip zone ID (e.g., fe80::1%eth0)
|
|
||||||
if "%" in ip:
|
|
||||||
ip = ip.split("%")[0]
|
|
||||||
addr = IPv6Address(ip)
|
|
||||||
|
|
||||||
# Special cases - return as-is or with minimal processing
|
|
||||||
if addr.is_loopback: # ::1
|
|
||||||
return "::1"
|
|
||||||
if addr.is_unspecified: # ::
|
|
||||||
return "::"
|
|
||||||
if addr.ipv4_mapped: # ::ffff:x.x.x.x
|
|
||||||
return str(addr.ipv4_mapped)
|
|
||||||
if addr.is_link_local: # fe80::/10 - interface-specific, keep full
|
|
||||||
return str(addr)
|
|
||||||
|
|
||||||
# Regular addresses: truncate to /64 network prefix
|
|
||||||
network_int = int(addr) >> 64
|
|
||||||
# Format as IPv6 with trailing ::
|
|
||||||
# Split into 4 groups of 16 bits
|
|
||||||
groups = []
|
|
||||||
for _ in range(4):
|
|
||||||
groups.insert(0, format(network_int & 0xFFFF, "x"))
|
|
||||||
network_int >>= 16
|
|
||||||
# Compress consecutive zero groups
|
|
||||||
result = ":".join(groups) + "::"
|
|
||||||
# Simplify leading zeros in groups and compress, then strip trailing ::
|
|
||||||
return str(IPv6Address(result + "0")).removesuffix("::")
|
|
||||||
except Exception:
|
|
||||||
return ip
|
|
||||||
|
|
||||||
|
|
||||||
def format_client_ip(ip: str) -> str:
|
|
||||||
"""Format client IP, compressing IPv6 to network part only."""
|
|
||||||
if not ip or ip == "-":
|
|
||||||
return "-"
|
|
||||||
# Strip brackets for detection (some proxies add them)
|
|
||||||
stripped = ip.strip("[]")
|
|
||||||
if ":" in stripped:
|
|
||||||
return format_ipv6_network(ip)
|
|
||||||
return ip
|
|
||||||
|
|
||||||
|
|
||||||
def status_color(status: int) -> str:
|
|
||||||
"""Return color code based on HTTP status."""
|
|
||||||
if status < 200:
|
|
||||||
return _STATUS_INFO
|
|
||||||
if status < 300:
|
|
||||||
return _STATUS_OK
|
|
||||||
if status < 400:
|
|
||||||
return _STATUS_REDIRECT
|
|
||||||
if status < 500:
|
|
||||||
return _STATUS_CLIENT_ERR
|
|
||||||
return _STATUS_SERVER_ERR
|
|
||||||
|
|
||||||
|
|
||||||
def method_color(method: str) -> str:
|
|
||||||
"""Return color code based on HTTP method."""
|
|
||||||
if method in ("GET", "HEAD", "OPTIONS"):
|
|
||||||
return _METHOD_READ
|
|
||||||
return _METHOD_WRITE
|
|
||||||
|
|
||||||
|
|
||||||
def format_access_log(
|
|
||||||
client: str,
|
|
||||||
status: int,
|
|
||||||
method: str,
|
|
||||||
host: str,
|
|
||||||
path: str,
|
|
||||||
duration_ms: float,
|
|
||||||
extra: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""Format access log line with colors and aligned fields."""
|
|
||||||
# Format components with fixed widths for alignment
|
|
||||||
ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
|
|
||||||
timing = f"{duration_ms:.0f}ms"
|
|
||||||
method_padded = method.ljust(7) # Longest method is OPTIONS (7)
|
|
||||||
|
|
||||||
status_str = f"{status_color(status)}{status}{_RESET}"
|
|
||||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
|
||||||
method_str = f"{method_color(method)}{method_padded}{_RESET}"
|
|
||||||
host_str = f"{_HOST}{host}{_RESET}"
|
|
||||||
path_str = f"{_PATH}{path}{_RESET}"
|
|
||||||
|
|
||||||
# Format: "IP STATUS METHOD host path [extra] TIMING"
|
|
||||||
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
|
||||||
return (
|
|
||||||
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# WebSocket connection counter (mod 100)
|
|
||||||
_ws_counter = 0
|
|
||||||
|
|
||||||
|
|
||||||
def _next_ws_id() -> int:
|
|
||||||
"""Get next WebSocket connection ID (0-99)."""
|
|
||||||
global _ws_counter
|
|
||||||
ws_id = _ws_counter
|
|
||||||
_ws_counter = (_ws_counter + 1) % 100
|
|
||||||
return ws_id
|
|
||||||
|
|
||||||
|
|
||||||
def log_ws_open(ws) -> int:
|
|
||||||
"""Log WebSocket connection open. Returns connection ID for use in close."""
|
|
||||||
ws_id = _next_ws_id()
|
|
||||||
|
|
||||||
client = ws.client.host if ws.client else "-"
|
|
||||||
host = ws.headers.get("host", "-")
|
|
||||||
path = ws.url.path
|
|
||||||
origin = ws.headers.get("origin")
|
|
||||||
|
|
||||||
ip = format_client_ip(client).ljust(19)
|
|
||||||
# ID right-aligned like status codes (3 chars), emoji formatted like method
|
|
||||||
id_str = f"{_WS_OPEN}{str(ws_id).rjust(3)}{_RESET}"
|
|
||||||
# Emoji (2 display width) + 6 spaces = 8 display chars, but within color for alignment
|
|
||||||
emoji_str = f"{_METHOD_READ}🔌 {_RESET}"
|
|
||||||
|
|
||||||
# Determine if origin should be shown (omit when same as host)
|
|
||||||
# Origin header includes scheme (e.g., "https://example.com"), compare host part
|
|
||||||
origin_host = origin.split("://", 1)[-1] if origin else None
|
|
||||||
show_origin = origin_host and origin_host != host
|
|
||||||
|
|
||||||
host_str = f"{_HOST}{host}{_RESET}"
|
|
||||||
path_str = f"{_PATH}{path}{_RESET}"
|
|
||||||
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
|
||||||
|
|
||||||
logger.info(f"{ip} {id_str} {emoji_str}{host_str}{path_str}{origin_str}")
|
|
||||||
return ws_id
|
|
||||||
|
|
||||||
|
|
||||||
# WebSocket close codes to human-readable status
|
|
||||||
WS_CLOSE_CODES = {
|
|
||||||
1000: "ok",
|
|
||||||
1001: "going away",
|
|
||||||
1002: "protocol error",
|
|
||||||
1003: "unsupported",
|
|
||||||
1005: "no status",
|
|
||||||
1006: "abnormal",
|
|
||||||
1007: "invalid data",
|
|
||||||
1008: "policy violation",
|
|
||||||
1009: "too large",
|
|
||||||
1010: "extension required",
|
|
||||||
1011: "server error",
|
|
||||||
1012: "restarting",
|
|
||||||
1013: "try again",
|
|
||||||
1014: "bad gateway",
|
|
||||||
1015: "tls error",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
|
||||||
"""Log WebSocket connection close with duration and status."""
|
|
||||||
# ID right-aligned like status codes (3 chars), "closed" formatted like method
|
|
||||||
id_str = f"{_WS_CLOSE}{str(ws_id).rjust(3)}{_RESET}"
|
|
||||||
# Pad within the dim color to keep full width in color (8 display chars)
|
|
||||||
closed_str = f"{_TIMING}closed {_RESET}"
|
|
||||||
timing = f"{duration * 1000:.0f}ms"
|
|
||||||
|
|
||||||
# Convert close code to status text
|
|
||||||
if close_code is None:
|
|
||||||
code = "----"
|
|
||||||
status = "unknown"
|
|
||||||
else:
|
|
||||||
code = str(close_code)
|
|
||||||
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
|
||||||
|
|
||||||
# Status code and text in normal color, not dim
|
|
||||||
status_str = f"{code} {status}"
|
|
||||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
|
||||||
|
|
||||||
logger.info(f"{' ' * 19} {id_str} {closed_str}{status_str} {timing_str}")
|
|
||||||
|
|
||||||
|
|
||||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
|
||||||
"""Middleware that logs HTTP requests with custom format."""
|
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next) -> Response:
|
|
||||||
start = time.perf_counter()
|
|
||||||
response = await call_next(request)
|
|
||||||
duration_ms = (time.perf_counter() - start) * 1000
|
|
||||||
|
|
||||||
client = request.client.host if request.client else "-"
|
|
||||||
host = request.headers.get("host", "-")
|
|
||||||
method = request.method
|
|
||||||
path = request.url.path
|
|
||||||
if request.url.query:
|
|
||||||
path = f"{path}?{request.url.query}"
|
|
||||||
status = response.status_code
|
|
||||||
|
|
||||||
extra = getattr(request.state, "log_extra", "")
|
|
||||||
|
|
||||||
line = format_access_log(
|
|
||||||
client, status, method, host, path, duration_ms, extra=extra
|
|
||||||
)
|
|
||||||
logger.info(line)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def configure_access_logging():
|
|
||||||
"""Configure the access logger to output to stderr."""
|
|
||||||
handler = logging.StreamHandler(sys.stderr)
|
|
||||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
||||||
logger.addHandler(handler)
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
logger.propagate = False
|
|
||||||
# Suppress uvicorn access logs to avoid duplicate request lines.
|
|
||||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
||||||
# Suppress uvicorn websocket "connection open/closed" messages.
|
|
||||||
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
|
||||||
# Suppress watchfiles "X changes detected" INFO messages (keep WARNING for reload notification)
|
|
||||||
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
|
|
||||||
+1
-19
@@ -34,12 +34,6 @@ from fastapi.responses import FileResponse, Response, StreamingResponse
|
|||||||
from fastapi_vue import Frontend
|
from fastapi_vue import Frontend
|
||||||
|
|
||||||
from mediahive.__main__ import DEVMODE
|
from mediahive.__main__ import DEVMODE
|
||||||
from mediahive.access_logging import (
|
|
||||||
AccessLogMiddleware,
|
|
||||||
configure_access_logging,
|
|
||||||
log_ws_close,
|
|
||||||
log_ws_open,
|
|
||||||
)
|
|
||||||
from mediahive.config import load_config
|
from mediahive.config import load_config
|
||||||
from mediahive.hivescan.images import close_image_client
|
from mediahive.hivescan.images import close_image_client
|
||||||
from mediahive.hivescan.scanner import RootScanner
|
from mediahive.hivescan.scanner import RootScanner
|
||||||
@@ -63,8 +57,6 @@ from mediahive.root_registry import Supervisor
|
|||||||
|
|
||||||
logger = logging.getLogger("mediahive.server")
|
logger = logging.getLogger("mediahive.server")
|
||||||
|
|
||||||
configure_access_logging()
|
|
||||||
|
|
||||||
MPC_BE_DEFAULT_PORT = 13579
|
MPC_BE_DEFAULT_PORT = 13579
|
||||||
|
|
||||||
# Suppress console windows when spawning subprocesses on Windows
|
# Suppress console windows when spawning subprocesses on Windows
|
||||||
@@ -769,9 +761,6 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE)
|
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE)
|
||||||
|
|
||||||
# Custom access logging (uvicorn access logs are suppressed in access_logging)
|
|
||||||
app.add_middleware(AccessLogMiddleware)
|
|
||||||
|
|
||||||
# Allow CORS for development
|
# Allow CORS for development
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -829,9 +818,6 @@ async def ws_endpoint(ws: WebSocket) -> None:
|
|||||||
attached_contexts = supervisor.all_contexts()
|
attached_contexts = supervisor.all_contexts()
|
||||||
outbound: asyncio.Queue[bytes] = asyncio.Queue()
|
outbound: asyncio.Queue[bytes] = asyncio.Queue()
|
||||||
|
|
||||||
start = time.perf_counter()
|
|
||||||
ws_id = log_ws_open(ws)
|
|
||||||
close_code: int | None = None
|
|
||||||
prev_root_ids: set[str] = set()
|
prev_root_ids: set[str] = set()
|
||||||
prev_meta: dict[str, tuple[str, str, str | None, bool]] = {}
|
prev_meta: dict[str, tuple[str, str, str | None, bool]] = {}
|
||||||
|
|
||||||
@@ -918,9 +904,7 @@ async def ws_endpoint(ws: WebSocket) -> None:
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
await ws.receive_text()
|
await ws.receive_text()
|
||||||
except WebSocketDisconnect as exc:
|
except WebSocketDisconnect, OSError, RuntimeError:
|
||||||
close_code = exc.code
|
|
||||||
except OSError, RuntimeError:
|
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
sender_task.cancel()
|
sender_task.cancel()
|
||||||
@@ -935,8 +919,6 @@ async def ws_endpoint(ws: WebSocket) -> None:
|
|||||||
if ctx is not None:
|
if ctx is not None:
|
||||||
ctx.store.remove_listener(listener)
|
ctx.store.remove_listener(listener)
|
||||||
|
|
||||||
log_ws_close(ws_id, close_code, time.perf_counter() - start)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Media actions ---
|
# --- Media actions ---
|
||||||
|
|
||||||
|
|||||||
+4
-7
@@ -8,7 +8,7 @@ dependencies = [
|
|||||||
"aiofiles>=25.1.0",
|
"aiofiles>=25.1.0",
|
||||||
"aiopathlib>=0.6.0",
|
"aiopathlib>=0.6.0",
|
||||||
"bencodepy>=0.9.5",
|
"bencodepy>=0.9.5",
|
||||||
"fastapi-vue>=0.5.2",
|
"fastapi-vue>=1.4.1",
|
||||||
"fastapi[standard]>=0.128.0",
|
"fastapi[standard]>=0.128.0",
|
||||||
"httpx[http2]>=0.28.1",
|
"httpx[http2]>=0.28.1",
|
||||||
"msgspec>=0.19",
|
"msgspec>=0.19",
|
||||||
@@ -36,14 +36,14 @@ artifacts = ["mediahive/frontend-build"]
|
|||||||
only-packages = true
|
only-packages = true
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist.hooks.custom]
|
[tool.hatch.build.targets.sdist.hooks.custom]
|
||||||
path = "scripts/fastapi-vue/build-frontend.py"
|
path = "scripts/fastapi-vue/buildhook.py"
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist.force-include]
|
[tool.hatch.build.targets.sdist.force-include]
|
||||||
"scripts/fastapi-vue/build-frontend.py" = "scripts/fastapi-vue/build-frontend.py"
|
"scripts/fastapi-vue/buildhook.py" = "scripts/fastapi-vue/buildhook.py"
|
||||||
"scripts/fastapi-vue/buildutil.py" = "scripts/fastapi-vue/buildutil.py"
|
"scripts/fastapi-vue/buildutil.py" = "scripts/fastapi-vue/buildutil.py"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel.hooks.custom]
|
[tool.hatch.build.targets.wheel.hooks.custom]
|
||||||
path = "scripts/fastapi-vue/build-frontend.py"
|
path = "scripts/fastapi-vue/buildhook.py"
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
package = true
|
package = true
|
||||||
@@ -123,6 +123,3 @@ ignore = [
|
|||||||
# Allow unused local variables in ctypes COM boilerplate
|
# Allow unused local variables in ctypes COM boilerplate
|
||||||
"F841",
|
"F841",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
|
||||||
"mediahive/access_logging.py" = ["BLE001", "G004"]
|
|
||||||
|
|||||||
Regular → Executable
+15
-7
@@ -9,9 +9,11 @@ import sys
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import tracerite
|
||||||
|
|
||||||
# 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[import-not-found]
|
from devutil import (
|
||||||
ProcessGroup,
|
ProcessGroup,
|
||||||
check_ports_free,
|
check_ports_free,
|
||||||
logger,
|
logger,
|
||||||
@@ -22,11 +24,15 @@ from devutil import ( # type: ignore[import-not-found]
|
|||||||
|
|
||||||
DEFAULT_VITE_PORT = 8420
|
DEFAULT_VITE_PORT = 8420
|
||||||
DEFAULT_DEV_PORT = 8421
|
DEFAULT_DEV_PORT = 8421
|
||||||
|
HEALTH = "/api/health?from=devserver.py"
|
||||||
|
|
||||||
|
|
||||||
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():
|
||||||
@@ -36,7 +42,7 @@ async def run_devserver(
|
|||||||
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
|
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
|
||||||
backurl, mediahive = setup_cli("mediahive", backend, DEFAULT_DEV_PORT)
|
backurl, mediahive = setup_cli("mediahive", backend, DEFAULT_DEV_PORT)
|
||||||
|
|
||||||
# Tell the everyone by environment (vite proxy and backend devmode use these)
|
# Tell everyone via environment (vite proxy and backend devmode use these)
|
||||||
os.environ["MEDIAHIVE_VITE_URL"] = viteurl
|
os.environ["MEDIAHIVE_VITE_URL"] = viteurl
|
||||||
os.environ["MEDIAHIVE_BACKEND_URL"] = backurl
|
os.environ["MEDIAHIVE_BACKEND_URL"] = backurl
|
||||||
os.environ["MEDIAHIVE_DEV"] = "1"
|
os.environ["MEDIAHIVE_DEV"] = "1"
|
||||||
@@ -45,11 +51,13 @@ 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(*mediahive, *(extra_args or []))
|
await pg.spawn(*mediahive, *(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() -> None:
|
def main() -> None:
|
||||||
|
"""Parse CLI arguments and run the devserver."""
|
||||||
|
tracerite.load()
|
||||||
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,
|
||||||
@@ -58,12 +66,12 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-l",
|
"-l",
|
||||||
"--listen",
|
"--listen",
|
||||||
metavar="host:port",
|
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()
|
||||||
@@ -72,7 +80,7 @@ def main() -> None:
|
|||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
scripts/devserver.py [args to mediahive]
|
Other options are forwarded to mediahive [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).
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
"""Hatch build hook for building Vue frontend during package build."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import ( # type: ignore[import-not-found]
|
|
||||||
BuildHookInterface,
|
|
||||||
)
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
from buildutil import build
|
|
||||||
|
|
||||||
|
|
||||||
class CustomBuildHook(BuildHookInterface):
|
|
||||||
def initialize(self, version, build_data) -> None:
|
|
||||||
super().initialize(version, build_data)
|
|
||||||
root = Path(self.root)
|
|
||||||
frontend_src = root / "frontend"
|
|
||||||
frontend_build = root / "mediahive" / "frontend-build"
|
|
||||||
|
|
||||||
# When building a wheel from sdist, frontend sources may be omitted
|
|
||||||
# while prebuilt assets are already present in mediahive/frontend-build.
|
|
||||||
if frontend_src.exists():
|
|
||||||
build(str(frontend_src))
|
|
||||||
return
|
|
||||||
|
|
||||||
if frontend_build.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
msg = (
|
|
||||||
"Frontend build is missing. Expected either source directory "
|
|
||||||
f"'{frontend_src}' or prebuilt assets in '{frontend_build}'."
|
|
||||||
)
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Hatch build hook for building Vue frontend during package build."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from buildutil import build
|
||||||
|
|
||||||
|
|
||||||
|
class CustomBuildHook(BuildHookInterface): # type: ignore[misc]
|
||||||
|
"""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)
|
||||||
|
build("frontend")
|
||||||
@@ -7,6 +7,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."""
|
||||||
@@ -31,81 +33,118 @@ def _check_node_version(node_path: str) -> None:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[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,9 +182,7 @@ def find_dev_tool() -> list[str]:
|
|||||||
|
|
||||||
if name == "bun":
|
if name == "bun":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Bun has a bug in WS proxying "
|
"Bun has a WS proxy bug (github.com/oven-sh/bun/issues/9882). Consider npm.",
|
||||||
"(https://github.com/oven-sh/bun/issues/9882). "
|
|
||||||
"Consider using npm instead."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return [tool, *dev_args[name]]
|
return [tool, *dev_args[name]]
|
||||||
@@ -178,9 +215,9 @@ 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) -> None:
|
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)
|
||||||
@@ -190,4 +227,4 @@ def build(folder: str = "frontend") -> None:
|
|||||||
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,33 +1,32 @@
|
|||||||
"""Utilities for the devserver script in the source repository.
|
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||||
|
|
||||||
Used only with development dependencies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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, Self
|
from typing import TYPE_CHECKING, Any, Self
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
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.
|
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||||
|
|
||||||
Acts like TaskGroup for processes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
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
|
||||||
@@ -38,7 +37,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."""
|
||||||
|
|
||||||
@@ -59,18 +59,14 @@ class ProcessGroup:
|
|||||||
raise SystemExit(1) from None
|
raise SystemExit(1) from None
|
||||||
|
|
||||||
async def __aenter__(self) -> Self:
|
async def __aenter__(self) -> Self:
|
||||||
"""Return this process group context manager."""
|
"""Enter the async context manager."""
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(
|
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
||||||
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) -> None:
|
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
|
||||||
@@ -98,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:
|
||||||
@@ -108,46 +104,71 @@ class ProcessGroup:
|
|||||||
await p.wait()
|
await p.wait()
|
||||||
|
|
||||||
|
|
||||||
async def check_ports_free(*urls: str) -> None:
|
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
||||||
"""Verify URLs are not responding (ports are free).
|
"""GET url with plain asyncio streams, return the response Server header.
|
||||||
|
|
||||||
Raise SystemExit if any endpoint responds.
|
Returns an empty string when the server responds without a Server header,
|
||||||
|
and None when the server is unreachable or doesn't answer in time.
|
||||||
"""
|
"""
|
||||||
|
parts = urlsplit(url)
|
||||||
|
host = parts.hostname or "localhost"
|
||||||
|
port = parts.port or (443 if parts.scheme == "https" else 80)
|
||||||
|
path = parts.path or "/"
|
||||||
|
if parts.query:
|
||||||
|
path += f"?{parts.query}"
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(timeout):
|
||||||
|
reader, writer = await asyncio.open_connection(host, port)
|
||||||
|
try:
|
||||||
|
writer.write(f"GET {path} HTTP/1.0\r\nHost: {host}\r\n\r\n".encode())
|
||||||
|
await writer.drain()
|
||||||
|
data = await reader.readuntil(b"\r\n\r\n")
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
except OSError, EOFError, ValueError, TimeoutError:
|
||||||
|
return None
|
||||||
|
for line in data.decode("latin-1").split("\r\n"):
|
||||||
|
if line.lower().startswith("server:"):
|
||||||
|
return line.split(":", 1)[1].strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
async def check(client: httpx.AsyncClient, url: str) -> None:
|
|
||||||
with suppress(httpx.RequestError):
|
async def check_ports_free(*urls: str) -> None:
|
||||||
res = await client.get(url, timeout=0.1)
|
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||||
server = res.headers.get("server", "server")
|
|
||||||
logger.warning("Conflicting %s already running at %s", server, url)
|
async def check(url: str) -> None:
|
||||||
|
server = await http_get_server(url, timeout=0.1)
|
||||||
|
if server is not None:
|
||||||
|
logger.warning(
|
||||||
|
"Conflicting %s already running at %s", server or "server", url
|
||||||
|
)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
await asyncio.gather(*[check(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:
|
for attempt in range(max_attempts):
|
||||||
for attempt in range(max_attempts):
|
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||||
try:
|
logger.info("✓ Backend ready!")
|
||||||
await client.get(full_url, timeout=1.0)
|
return
|
||||||
logger.info("✓ Backend ready!")
|
if attempt == max_attempts - 1:
|
||||||
return
|
logger.warning("Backend didn't start in time")
|
||||||
except httpx.RequestError:
|
raise SystemExit(1)
|
||||||
if attempt == max_attempts - 1:
|
await asyncio.sleep(0.1)
|
||||||
logger.warning("Backend didn't start in time")
|
|
||||||
raise SystemExit(1)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -173,7 +194,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.
|
||||||
|
|
||||||
@@ -205,7 +228,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.
|
||||||
|
|
||||||
@@ -221,5 +246,7 @@ def setup_cli(
|
|||||||
host = endpoints[0]["host"]
|
host = endpoints[0]["host"]
|
||||||
port = endpoints[0]["port"]
|
port = endpoints[0]["port"]
|
||||||
|
|
||||||
cmd = [cli, f"--listen={host}:{port}"]
|
# Run the package as a module with the current interpreter, instead of
|
||||||
|
# relying on a PATH-installed CLI entry point.
|
||||||
|
cmd = [sys.executable, "-m", cli, f"--listen={host}:{port}"]
|
||||||
return f"http://{host}:{port}", cmd
|
return f"http://{host}:{port}", cmd
|
||||||
|
|||||||
Reference in New Issue
Block a user