Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02b3890cd3 | ||
|
|
4c0be1bfa7 | ||
|
|
beca6806ef | ||
|
|
c25835f467 |
+22
-4
@@ -9,6 +9,7 @@ Options:
|
|||||||
--module-name NAME Python module name (auto-detected from pyproject.toml)
|
--module-name NAME Python module name (auto-detected from pyproject.toml)
|
||||||
--ports DEFAULT,VITE,DEV Port configuration (default: 3100,3100,3200)
|
--ports DEFAULT,VITE,DEV Port configuration (default: 3100,3100,3200)
|
||||||
--dry Show what would be done without making changes
|
--dry Show what would be done without making changes
|
||||||
|
-- ARGS Extra arguments forwarded to create-vue (e.g. -- --default)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -1211,7 +1212,9 @@ def ensure_python_project(project_dir: Path, *, dry: bool = False) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def ensure_frontend(project_dir: Path, *, dry: bool = False) -> bool:
|
def ensure_frontend(
|
||||||
|
project_dir: Path, *, vue_args: list[str] | None = None, dry: bool = False
|
||||||
|
) -> bool:
|
||||||
"""Ensure frontend directory exists with a Vue project, run create-vue if needed."""
|
"""Ensure frontend directory exists with a Vue project, run create-vue if needed."""
|
||||||
frontend_dir = project_dir / "frontend"
|
frontend_dir = project_dir / "frontend"
|
||||||
package_json = frontend_dir / "package.json"
|
package_json = frontend_dir / "package.json"
|
||||||
@@ -1234,6 +1237,10 @@ def ensure_frontend(project_dir: Path, *, dry: bool = False) -> bool:
|
|||||||
"bun": [js_tool, "create", "vue@latest", "frontend"],
|
"bun": [js_tool, "create", "vue@latest", "frontend"],
|
||||||
}
|
}
|
||||||
create_cmd = create_vue_commands[js_name]
|
create_cmd = create_vue_commands[js_name]
|
||||||
|
if vue_args:
|
||||||
|
# npm needs a `--` separator so it doesn't eat the arguments;
|
||||||
|
# create-vue runs non-interactively when given feature flags (e.g. --default)
|
||||||
|
create_cmd = [*create_cmd, *(["--"] if js_name == "npm" else []), *vue_args]
|
||||||
|
|
||||||
if dry:
|
if dry:
|
||||||
print(f"🎨 Would run: {' '.join(create_cmd)}")
|
print(f"🎨 Would run: {' '.join(create_cmd)}")
|
||||||
@@ -1241,7 +1248,8 @@ def ensure_frontend(project_dir: Path, *, dry: bool = False) -> bool:
|
|||||||
|
|
||||||
print("🎨 No frontend/ found, creating Vue project...")
|
print("🎨 No frontend/ found, creating Vue project...")
|
||||||
print(f">>> {' '.join(create_cmd)}")
|
print(f">>> {' '.join(create_cmd)}")
|
||||||
print("(Follow the prompts to configure your Vue app)")
|
if not vue_args:
|
||||||
|
print("(Follow the prompts to configure your Vue app)")
|
||||||
print()
|
print()
|
||||||
result = subprocess.run(create_cmd, cwd=project_dir, check=False) # noqa: S603
|
result = subprocess.run(create_cmd, cwd=project_dir, check=False) # noqa: S603
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
@@ -1285,7 +1293,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
print(f"🔧 Setting up project: {project_dir}")
|
print(f"🔧 Setting up project: {project_dir}")
|
||||||
|
|
||||||
# Step 1: Ensure frontend exists (do this first so cancellation doesn't leave partial setup)
|
# Step 1: Ensure frontend exists (do this first so cancellation doesn't leave partial setup)
|
||||||
if not ensure_frontend(project_dir, dry=dry):
|
if not ensure_frontend(project_dir, vue_args=args.vue_args, dry=dry):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Step 2: Ensure Python project exists
|
# Step 2: Ensure Python project exists
|
||||||
@@ -1663,6 +1671,8 @@ Examples:
|
|||||||
fastapi-vue-setup . Set up integration in current directory
|
fastapi-vue-setup . Set up integration in current directory
|
||||||
fastapi-vue-setup . --dry Preview what would be done
|
fastapi-vue-setup . --dry Preview what would be done
|
||||||
fastapi-vue-setup . --ports=8000,5173,8080 Change default ports (backend, vite dev, backend dev)
|
fastapi-vue-setup . --ports=8000,5173,8080 Change default ports (backend, vite dev, backend dev)
|
||||||
|
fastapi-vue-setup my-app -- --default Non-interactive create-vue (extra args after --
|
||||||
|
are forwarded to create-vue, e.g. --default, --ts)
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -1685,7 +1695,15 @@ Examples:
|
|||||||
)
|
)
|
||||||
parser.add_argument("--dry", "--dry-run", action="store_true", help="Show what would be done")
|
parser.add_argument("--dry", "--dry-run", action="store_true", help="Show what would be done")
|
||||||
|
|
||||||
args = parser.parse_args()
|
# Everything after a standalone `--` is forwarded verbatim to create-vue
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
if "--" in argv:
|
||||||
|
split = argv.index("--")
|
||||||
|
ours, vue_args = argv[:split], argv[split + 1 :]
|
||||||
|
else:
|
||||||
|
ours, vue_args = argv, []
|
||||||
|
args = parser.parse_args(ours)
|
||||||
|
args.vue_args = vue_args
|
||||||
|
|
||||||
if args.project_dir is None:
|
if args.project_dir is None:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import tracerite
|
import tracerite
|
||||||
@@ -48,11 +48,11 @@ async def run_devserver(
|
|||||||
os.environ["ENVPREFIX_DEV"] = "1"
|
os.environ["ENVPREFIX_DEV"] = "1"
|
||||||
|
|
||||||
async with ProcessGroup() as pg:
|
async with ProcessGroup() as pg:
|
||||||
|
pg.create_task(check_ports_free(viteurl, backurl))
|
||||||
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 pg.spawn(*MODULE_NAME, *(extra_args or []), vital=True)
|
||||||
await pg.spawn(*MODULE_NAME, *(extra_args or []))
|
|
||||||
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
||||||
await pg.spawn(*vite, cwd=front)
|
await pg.spawn(*vite, cwd=front, vital=True)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -75,8 +75,12 @@ def main() -> None:
|
|||||||
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()
|
||||||
with suppress(KeyboardInterrupt):
|
try:
|
||||||
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
||||||
|
except* KeyboardInterrupt:
|
||||||
|
pass # user stopped the devserver: normal exit
|
||||||
|
except* (subprocess.SubprocessError, RuntimeError):
|
||||||
|
raise SystemExit(1) from None # logged in devutil already; exit 1
|
||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
|
|||||||
@@ -2,106 +2,78 @@
|
|||||||
"""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 sys
|
import sys
|
||||||
|
from asyncio.subprocess import Process
|
||||||
|
from collections.abc import Awaitable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Self
|
from subprocess import CalledProcessError
|
||||||
|
from typing import Any
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
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(asyncio.TaskGroup):
|
||||||
|
"""TaskGroup with structured ownership of async subprocesses."""
|
||||||
|
|
||||||
class ProcessGroup:
|
def __init__(self, *, terminate_timeout: float = 10) -> None:
|
||||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
"""Set the grace period before terminate() escalates to kill()."""
|
||||||
|
super().__init__()
|
||||||
|
self._terminate_timeout = terminate_timeout
|
||||||
|
self._cmds: dict[Process, tuple[str, ...]] = {}
|
||||||
|
|
||||||
def __init__(self) -> None:
|
async def spawn(self, *cmd: str, cwd: str | None = None, vital: bool = False) -> Process:
|
||||||
"""Initialize empty process tracking."""
|
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
|
||||||
self._procs: list[asyncio.subprocess.Process] = []
|
|
||||||
self._cmds: dict[int, str] = {} # pid -> command name
|
|
||||||
|
|
||||||
async def spawn(
|
async def run() -> None:
|
||||||
self,
|
name = Path(cmd[0]).stem
|
||||||
*cmd: str,
|
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
|
||||||
cwd: str | None = None,
|
try:
|
||||||
) -> asyncio.subprocess.Process:
|
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||||
"""Spawn a subprocess and track it."""
|
self._cmds[proc] = cmd
|
||||||
cmd_name = Path(cmd[0]).stem
|
started.set_result(proc)
|
||||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
except Exception as e: # noqa: BLE001
|
||||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
started.set_exception(e)
|
||||||
self._procs.append(proc)
|
return
|
||||||
self._cmds[proc.pid] = cmd_name
|
|
||||||
return proc
|
|
||||||
|
|
||||||
async def wait(
|
try:
|
||||||
self,
|
returncode = await proc.wait()
|
||||||
*waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]",
|
finally:
|
||||||
) -> None:
|
|
||||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
|
||||||
|
|
||||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
|
||||||
returncode = await proc.wait()
|
|
||||||
if returncode != 0:
|
|
||||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
|
||||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
|
||||||
|
|
||||||
tasks = [
|
|
||||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w for w in waitables
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
|
||||||
raise SystemExit(1) from None
|
|
||||||
|
|
||||||
async def __aenter__(self) -> Self:
|
|
||||||
"""Enter the async context manager."""
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
|
||||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
|
||||||
await self._cleanup(immediate=exc_type is not None)
|
|
||||||
|
|
||||||
async def _cleanup(self, *, immediate: bool = False) -> None:
|
|
||||||
running = [p for p in self._procs if p.returncode is None]
|
|
||||||
if not running:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not immediate:
|
|
||||||
# Wait for any one process to exit
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await asyncio.wait(
|
|
||||||
[asyncio.create_task(p.wait()) for p in running],
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Terminate remaining processes
|
|
||||||
for p in self._procs:
|
|
||||||
if p.returncode is None:
|
|
||||||
with suppress(ProcessLookupError):
|
with suppress(ProcessLookupError):
|
||||||
p.terminate()
|
proc.terminate()
|
||||||
|
|
||||||
# Wait for all to finish (with overall timeout), shielded from cancellation
|
|
||||||
still_running = [p for p in self._procs if p.returncode is None]
|
|
||||||
if still_running:
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
try:
|
try:
|
||||||
await asyncio.shield(
|
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
|
||||||
asyncio.wait_for(
|
|
||||||
asyncio.gather(*[p.wait() for p in still_running]),
|
|
||||||
timeout=10,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
for p in self._procs:
|
with suppress(ProcessLookupError):
|
||||||
if p.returncode is None:
|
proc.kill()
|
||||||
with suppress(ProcessLookupError):
|
await proc.wait()
|
||||||
p.kill()
|
|
||||||
await p.wait()
|
if vital:
|
||||||
|
logger.warning("Vital process %s exited", name)
|
||||||
|
raise CalledProcessError(returncode, cmd)
|
||||||
|
|
||||||
|
started = asyncio.get_running_loop().create_future()
|
||||||
|
self.create_task(run())
|
||||||
|
return await asyncio.shield(started)
|
||||||
|
|
||||||
|
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
|
||||||
|
"""Wait concurrently and return results in argument order."""
|
||||||
|
|
||||||
|
async def task(w: Process | Awaitable) -> Any: # noqa: ANN401
|
||||||
|
if not isinstance(w, Process):
|
||||||
|
return await w
|
||||||
|
if retcode := await w.wait():
|
||||||
|
cmd = self._cmds[w]
|
||||||
|
logger.warning("Process %s exited with status %d", Path(cmd[0]).stem, retcode)
|
||||||
|
raise CalledProcessError(retcode, cmd)
|
||||||
|
return retcode
|
||||||
|
|
||||||
|
async with asyncio.TaskGroup() as group:
|
||||||
|
tasks = [group.create_task(task(w)) for w in waitables]
|
||||||
|
|
||||||
|
return tuple(task.result() for task in tasks)
|
||||||
|
|
||||||
|
|
||||||
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
||||||
@@ -127,29 +99,30 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN
|
|||||||
writer.close()
|
writer.close()
|
||||||
except (OSError, EOFError, ValueError, TimeoutError):
|
except (OSError, EOFError, ValueError, TimeoutError):
|
||||||
return None
|
return None
|
||||||
for line in data.decode("latin-1").split("\r\n"):
|
for line in data.decode(errors="replace").split("\r\n"):
|
||||||
if line.lower().startswith("server:"):
|
if line.lower().startswith("server:"):
|
||||||
return line.split(":", 1)[1].strip()
|
return line[7:].strip()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def check_ports_free(*urls: str) -> None:
|
async def check_ports_free(*urls: str) -> None:
|
||||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
"""Verify URLs are not responding (ports are free).
|
||||||
|
|
||||||
async def check(url: str) -> None:
|
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
|
||||||
server = await http_get_server(url, timeout=0.1)
|
RuntimeError (handled like a failed process) if any URL responds.
|
||||||
|
"""
|
||||||
|
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
|
||||||
|
for url, server in zip(urls, servers, strict=True):
|
||||||
if server is not None:
|
if server is not None:
|
||||||
logger.warning("Conflicting %s already running at %s", server or "server", url)
|
logger.error("Conflicting %s already running at %s", server or "server", url)
|
||||||
raise SystemExit(1)
|
raise RuntimeError(url)
|
||||||
|
|
||||||
await asyncio.gather(*[check(url) for url in urls])
|
|
||||||
|
|
||||||
|
|
||||||
async def ready(url: str, path: str = "", max_attempts: int = 50) -> 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.
|
Use empty path to disable the check and make this return immediately.
|
||||||
Raises SystemExit(1) if server doesn't start in time.
|
Raises TimeoutError if server doesn't start in time.
|
||||||
"""
|
"""
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
@@ -159,8 +132,7 @@ async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
|||||||
logger.info("✓ Backend ready!")
|
logger.info("✓ Backend ready!")
|
||||||
return
|
return
|
||||||
if attempt == max_attempts - 1:
|
if attempt == max_attempts - 1:
|
||||||
logger.warning("Backend didn't start in time")
|
raise TimeoutError(f"Backend at {url} didn't start in time") # noqa: EM102, TRY003
|
||||||
raise SystemExit(1)
|
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user