Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aaf040db7 | ||
|
|
aac27da67c | ||
|
|
79e645a263 |
@@ -12,6 +12,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}]
|
||||||
@@ -65,3 +66,19 @@ def parse_endpoint(value: str | None, default_port: int = 0) -> list[dict]:
|
|||||||
ipaddress.ip_address(host)
|
ipaddress.ip_address(host)
|
||||||
|
|
||||||
return [{"host": host, "port": port}]
|
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)]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from contextlib import suppress
|
|||||||
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__)
|
||||||
|
|
||||||
@@ -30,13 +30,9 @@ def run(
|
|||||||
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):
|
raise ValueError("No endpoints to serve; check listen configuration")
|
||||||
listen = [listen]
|
|
||||||
endpoints: list[dict] = []
|
|
||||||
for ep in listen:
|
|
||||||
endpoints.extend(parse_endpoint(ep, default_port))
|
|
||||||
|
|
||||||
conf: dict[str, object] = {"app": app, "reload": reload, "workers": workers}
|
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,7 +41,7 @@ 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:
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ def uv_add_packages(
|
|||||||
# If vite == dev, dev is incremented by 100
|
# If vite == dev, dev is incremented by 100
|
||||||
DEFAULT_PORTS = (3100, 3100, 3200)
|
DEFAULT_PORTS = (3100, 3100, 3200)
|
||||||
|
|
||||||
|
# Default health check path for devserver backend readiness check
|
||||||
|
DEFAULT_HEALTH = "/api/health?from=devserver.py"
|
||||||
|
|
||||||
# Marker comment indicating file can be auto-upgraded
|
# Marker comment indicating file can be auto-upgraded
|
||||||
# Users should remove this line to prevent automatic updates
|
# Users should remove this line to prevent automatic updates
|
||||||
UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup"
|
UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup"
|
||||||
@@ -300,6 +303,29 @@ def extract_existing_ports(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Sentinel for "not found" in extract_existing_health
|
||||||
|
_HEALTH_NOT_FOUND = object()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_existing_health(project_dir: Path) -> str | object:
|
||||||
|
"""Extract existing health path configuration from devserver.py.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- The path string (may be empty to disable)
|
||||||
|
- _HEALTH_NOT_FOUND sentinel if not found or file doesn't exist
|
||||||
|
"""
|
||||||
|
devserver_file = project_dir / "scripts" / "devserver.py"
|
||||||
|
if not devserver_file.exists():
|
||||||
|
return _HEALTH_NOT_FOUND
|
||||||
|
|
||||||
|
content = devserver_file.read_text("UTF-8")
|
||||||
|
# Match HEALTH = "/path" or HEALTH = ""
|
||||||
|
match = re.search(r'^HEALTH\s*=\s*"([^"]*)"', content, re.MULTILINE)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return _HEALTH_NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
def load_template(path: str) -> str:
|
def load_template(path: str) -> str:
|
||||||
"""Load a template file from the template directory."""
|
"""Load a template file from the template directory."""
|
||||||
return (TEMPLATE_DIR / path).read_text("UTF-8")
|
return (TEMPLATE_DIR / path).read_text("UTF-8")
|
||||||
@@ -1323,6 +1349,27 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
f"📡 Ports: default={default_port}, vite={vite_port}, dev={dev_port} {ports_note}"
|
f"📡 Ports: default={default_port}, vite={vite_port}, dev={dev_port} {ports_note}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Determine health path configuration
|
||||||
|
# Priority: --health argument > existing project value > default
|
||||||
|
if args.health is not None:
|
||||||
|
health = args.health
|
||||||
|
health_note = "(--health)" if health else "(disabled via --health)"
|
||||||
|
else:
|
||||||
|
existing_health = extract_existing_health(project_dir)
|
||||||
|
if existing_health is not _HEALTH_NOT_FOUND:
|
||||||
|
# Explicitly configured (path string, possibly empty to disable)
|
||||||
|
health = existing_health
|
||||||
|
health_note = "(kept for upgrade)"
|
||||||
|
else:
|
||||||
|
# Not found - use default
|
||||||
|
health = DEFAULT_HEALTH
|
||||||
|
health_note = "(--health to override)"
|
||||||
|
|
||||||
|
if health:
|
||||||
|
print(f"🏥 Health check: {health} {health_note}")
|
||||||
|
else:
|
||||||
|
print(f"🏥 Health check: disabled {health_note}")
|
||||||
|
|
||||||
# Title for templates
|
# Title for templates
|
||||||
project_title = module_name.replace("_", " ").title()
|
project_title = module_name.replace("_", " ").title()
|
||||||
|
|
||||||
@@ -1338,6 +1385,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
"TEMPLATE_DEFAULT_PORT": str(default_port),
|
"TEMPLATE_DEFAULT_PORT": str(default_port),
|
||||||
"TEMPLATE_VITE_PORT": str(vite_port),
|
"TEMPLATE_VITE_PORT": str(vite_port),
|
||||||
"TEMPLATE_DEV_PORT": str(dev_port),
|
"TEMPLATE_DEV_PORT": str(dev_port),
|
||||||
|
"TEMPLATE_HEALTH": f'"{health}"',
|
||||||
"ENVPREFIX": module_name.upper(),
|
"ENVPREFIX": module_name.upper(),
|
||||||
"PROJECT_CLI": module_name,
|
"PROJECT_CLI": module_name,
|
||||||
"MAIN_MODULE": main_module_path,
|
"MAIN_MODULE": main_module_path,
|
||||||
@@ -1635,6 +1683,11 @@ Examples:
|
|||||||
metavar="BACKEND,VITE,DEV",
|
metavar="BACKEND,VITE,DEV",
|
||||||
help="Port configuration as comma-separated values (default: 3100,3100,3200)",
|
help="Port configuration as comma-separated values (default: 3100,3100,3200)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--health",
|
||||||
|
metavar="PATH",
|
||||||
|
help="Health check path for devserver (default: /api/health?from=devserver.py, '' to disable)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dry", "--dry-run", action="store_true", help="Show what would be done"
|
"--dry", "--dry-run", action="store_true", help="Show what would be done"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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(
|
||||||
@@ -45,7 +46,7 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -111,18 +111,19 @@ 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=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!")
|
logger.info("✓ Backend ready!")
|
||||||
return
|
return
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
|
|||||||
Reference in New Issue
Block a user