Compare commits

..
5 Commits
5 changed files with 125 additions and 107 deletions
+20 -2
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import io import io
import logging import logging
import os
import re import re
import sys import sys
from contextlib import suppress from contextlib import suppress
@@ -43,6 +44,21 @@ def strip_ansi(text: str) -> str:
return ANSI_ESCAPE_RE.sub("", text) return ANSI_ESCAPE_RE.sub("", text)
def use_color(stream: io.TextIOBase = sys.stderr) -> bool:
"""Test if the stream supports color codes."""
if os.environ.get("NO_COLOR"): # Non empty means no (no-color.org)
return False
if os.environ.get("FORCE_COLOR", "") not in {"", "0"}: # force-color.org, node
return True
if hasattr(stream, "isatty") and stream.isatty():
return True
with suppress(KeyError, ValueError, OSError): # Journald does color (-ocat)
dev, ino = map(int, os.environ["JOURNAL_STREAM"].split(":", 1))
st = os.fstat(stream.fileno())
return st.st_dev == dev and st.st_ino == ino
return False
_LEVEL_EMOJI = { _LEVEL_EMOJI = {
logging.DEBUG: "🐛", logging.DEBUG: "🐛",
logging.INFO: "🔷", logging.INFO: "🔷",
@@ -94,7 +110,7 @@ class Formatter(logging.Formatter):
if use_colors in (True, False): if use_colors in (True, False):
self.use_colors = use_colors self.use_colors = use_colors
else: else:
self.use_colors = sys.stdout.isatty() self.use_colors = use_color(sys.stdout)
super().__init__(fmt=fmt, datefmt=datefmt, style=style) super().__init__(fmt=fmt, datefmt=datefmt, style=style)
def formatMessage(self, record: logging.LogRecord) -> str: # noqa: N802 def formatMessage(self, record: logging.LogRecord) -> str: # noqa: N802
@@ -349,7 +365,9 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
# watchfiles logs "N changes detected" to its own logger at INFO; only the # watchfiles logs "N changes detected" to its own logger at INFO; only the
# WARNING "Reloading..." line (uvicorn.error) should show. # WARNING "Reloading..." line (uvicorn.error) should show.
with suppress(Exception): with suppress(Exception):
config.setdefault("loggers", {}).setdefault("watchfiles.main", {}).setdefault("level", "WARNING") config.setdefault("loggers", {}).setdefault("watchfiles.main", {}).setdefault(
"level", "WARNING"
)
# kanta-style output (diffs, colored headers) prints without prefixes, # kanta-style output (diffs, colored headers) prints without prefixes,
# like our access log. A user-supplied "kanta" logger entry wins. # like our access log. A user-supplied "kanta" logger entry wins.
+6
View File
@@ -18,11 +18,17 @@ from .logging import (
patch_lifespan_logging, patch_lifespan_logging,
patch_log_config, patch_log_config,
patch_server_error_middleware, patch_server_error_middleware,
use_color,
) )
from .startupbox import print_box from .startupbox import print_box
tracerite.load() # Early load on CLI load (import server); uvicorn workers reload via log config tracerite.load() # Early load on CLI load (import server); uvicorn workers reload via log config
# Install force color to aid tracerite and any external software to use full color when available
# Define NO_COLOR or FORCE_COLOR beforehand to avoid this
if "FORCE_COLOR" not in os.environ and use_color():
os.environ["FORCE_COLOR"] = "3"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_WILDCARD_HOSTS = frozenset({"0.0.0.0", "::"}) # noqa: S104 _WILDCARD_HOSTS = frozenset({"0.0.0.0", "::"}) # noqa: S104
+21 -3
View File
@@ -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,6 +1248,7 @@ 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)}")
if not vue_args:
print("(Follow the prompts to configure your Vue app)") 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
@@ -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()
+9 -5
View File
@@ -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 = """
+67 -95
View File
@@ -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,
) -> asyncio.subprocess.Process:
"""Spawn a subprocess and track it."""
cmd_name = Path(cmd[0]).stem
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._procs.append(proc)
self._cmds[proc.pid] = cmd_name
return proc
async def wait(
self,
*waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]",
) -> None:
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
returncode = await proc.wait()
if returncode != 0:
cmd_name = self._cmds.get(proc.pid, "unknown")
raise subprocess.CalledProcessError(returncode, cmd_name)
tasks = [
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w for w in waitables
]
try: try:
await asyncio.gather(*tasks) proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
except subprocess.CalledProcessError as e: self._cmds[proc] = cmd
logger.warning("%s failed with exit status %d", e.cmd, e.returncode) started.set_result(proc)
raise SystemExit(1) from None except Exception as e: # noqa: BLE001
started.set_exception(e)
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 return
if not immediate:
# Wait for any one process to exit
with suppress(asyncio.CancelledError):
await asyncio.wait(
[asyncio.create_task(p.wait()) for p in running],
return_when=asyncio.FIRST_COMPLETED,
)
# Terminate remaining processes
for p in self._procs:
if p.returncode is None:
with suppress(ProcessLookupError):
p.terminate()
# Wait for all to finish (with overall timeout), shielded from cancellation
still_running = [p for p in self._procs if p.returncode is None]
if still_running:
with suppress(asyncio.CancelledError):
try: try:
await asyncio.shield( returncode = await proc.wait()
asyncio.wait_for( finally:
asyncio.gather(*[p.wait() for p in still_running]),
timeout=10,
),
)
except TimeoutError:
for p in self._procs:
if p.returncode is None:
with suppress(ProcessLookupError): with suppress(ProcessLookupError):
p.kill() proc.terminate()
await p.wait() try:
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
except TimeoutError:
with suppress(ProcessLookupError):
proc.kill()
await proc.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)