From a84041a92335bf3ae3730f4a4a8b7e896d8d9a26 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 2 Feb 2026 19:56:31 +0000 Subject: [PATCH] Refactor with new fastapi-vue utility module to run the server, used in CLI main and devserver to run the main app. Default ports made part of templating, so that --ports can be easily specified to fastapi-vue-setup to customize (respects earlier choices when no --ports is specified). New overall default ports 3100, 3200. --- fastapi_vue_setup.py | 116 +++++++++++++++++++++++- template/backend/__main__.py | 41 +++------ template/scripts/devserver.py | 26 +++--- template/scripts/fastapi-vue/devutil.py | 38 ++++---- 4 files changed, 157 insertions(+), 64 deletions(-) diff --git a/fastapi_vue_setup.py b/fastapi_vue_setup.py index 3a672bc..04ff4f5 100644 --- a/fastapi_vue_setup.py +++ b/fastapi_vue_setup.py @@ -7,6 +7,7 @@ Usage: Options: --module-name NAME Python module name (auto-detected from pyproject.toml) + --ports DEFAULT,VITE,DEV Port configuration (default: 3100,3100,3200) --dry-run Show what would be done without making changes """ @@ -24,6 +25,10 @@ import tomlkit # Template directory TEMPLATE_DIR = Path(__file__).parent / "template" +# Default ports: (default, vite, dev) +# If vite == dev, dev is incremented by 100 +DEFAULT_PORTS = (3100, 3100, 3200) + # Marker comment indicating file can be auto-upgraded # Users should remove this line to prevent automatic updates UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup" @@ -117,6 +122,80 @@ Next steps: # ============================================================================= +def parse_ports(ports_str: str | None) -> tuple[int, int, int]: + """Parse comma-separated port string into (default, vite, dev) tuple. + + If dev == vite, dev is incremented by 100 to avoid conflicts. + """ + if not ports_str: + return DEFAULT_PORTS + + parts = ports_str.split(",") + if len(parts) == 1: + default = int(parts[0]) + vite = default + dev = default + 100 + elif len(parts) == 2: + default = int(parts[0]) + vite = int(parts[1]) + dev = vite + 100 if vite == default else default + 100 + elif len(parts) == 3: + default = int(parts[0]) + vite = int(parts[1]) + dev = int(parts[2]) + else: + raise ValueError(f"Invalid ports format: {ports_str}") + + # Auto-adjust dev if it conflicts with vite + if dev == vite: + dev = vite + 100 + + return default, vite, dev + + +def extract_existing_ports(project_dir: Path) -> tuple[int, int, int] | None: + """Extract existing port configuration from project files. + + Returns (default, vite, dev) or None if not found. + """ + module_name = find_module_name(project_dir) + if not module_name: + return None + + default_port = None + vite_port = None + dev_port = None + + # Try to extract DEFAULT_PORT from __main__.py + main_file = project_dir / module_name / "__main__.py" + if main_file.exists(): + content = main_file.read_text("UTF-8") + match = re.search(r"DEFAULT_PORT\s*=\s*(\d+)", content) + if match: + default_port = int(match.group(1)) + + # Try to extract ports from devserver.py + devserver_file = project_dir / "scripts" / "devserver.py" + if devserver_file.exists(): + content = devserver_file.read_text("UTF-8") + match = re.search(r"DEFAULT_VITE_PORT\s*=\s*(\d+)", content) + if match: + vite_port = int(match.group(1)) + match = re.search(r"DEFAULT_DEV_PORT\s*=\s*(\d+)", content) + if match: + dev_port = int(match.group(1)) + + # Return only if we found at least one port + if default_port is not None or vite_port is not None or dev_port is not None: + return ( + default_port or DEFAULT_PORTS[0], + vite_port or DEFAULT_PORTS[1], + dev_port or DEFAULT_PORTS[2], + ) + + return None + + def load_template(path: str) -> str: """Load a template file from the template directory.""" return (TEMPLATE_DIR / path).read_text("UTF-8") @@ -943,10 +1022,31 @@ def cmd_setup(args: argparse.Namespace) -> int: print(f"📦 Module: {module_name}") + # Determine port configuration + # Priority: --ports argument > existing project values > defaults + if args.ports: + default_port, vite_port, dev_port = parse_ports(args.ports) + ports_note = "(--ports)" + else: + existing_ports = extract_existing_ports(project_dir) + if existing_ports: + default_port, vite_port, dev_port = existing_ports + ports_note = "(kept for upgrade)" + else: + default_port, vite_port, dev_port = DEFAULT_PORTS + ports_note = "(--ports to override)" + + print( + f"📡 Ports: default={default_port}, vite={vite_port}, dev={dev_port} {ports_note}" + ) + # Template variables tpl_vars = { "MODULE_NAME": module_name, "PROJECT_TITLE": project_title, + "TEMPLATE_DEFAULT_PORT": str(default_port), + "TEMPLATE_VITE_PORT": str(vite_port), + "TEMPLATE_DEV_PORT": str(dev_port), } module_dir = project_dir / module_name @@ -1137,7 +1237,15 @@ def cmd_setup(args: argparse.Namespace) -> int: print("✅ Created .gitignore") # === Add dependencies using uv === - uv_add_main = ["uv", "add", "-q", "-U", "--no-sync", "fastapi[standard]", "fastapi-vue"] + uv_add_main = [ + "uv", + "add", + "-q", + "-U", + "--no-sync", + "fastapi[standard]", + "fastapi-vue", + ] uv_add_dev = ["uv", "add", "-q", "-U", "--group", "dev", "httpx"] if dry_run: print(f"[DRY RUN] Would run: {' '.join(uv_add_main)}") @@ -1215,6 +1323,7 @@ Examples: fastapi-vue-setup my-new-project Create a new project from scratch fastapi-vue-setup . Set up integration in current directory fastapi-vue-setup . --dry-run Preview what would be done + fastapi-vue-setup . --ports 8000,5173,8080 Custom ports (default,vite,dev) """, ) parser.add_argument( @@ -1224,6 +1333,11 @@ Examples: help="Project directory (use . for current directory)", ) parser.add_argument("--module-name", help="Python module name (auto-detected)") + parser.add_argument( + "--ports", + metavar="DEFAULT,VITE,DEV", + help="Port configuration as comma-separated values (default: 3100,3100,3200)", + ) parser.add_argument( "--dry-run", action="store_true", help="Show what would be done" ) diff --git a/template/backend/__main__.py b/template/backend/__main__.py index 7ed2aa5..cb0f2d7 100644 --- a/template/backend/__main__.py +++ b/template/backend/__main__.py @@ -1,44 +1,25 @@ # auto-upgrade@fastapi-vue-setup - remove this if you modify this file import argparse -import asyncio -import os -from fastapi_vue.hostutil import parse_endpoint -from uvicorn import Config, Server +from fastapi_vue import server -DEFAULT_PORT = 5080 - - -def run_server(endpoints: list[dict], *, proxy=""): - conf: dict[str, object] = {"app": "MODULE_NAME.APP_MODULE:APP_VAR"} - if proxy: - conf["proxy_headers"] = True - conf["forwarded_allow_ips"] = proxy - - async def serve_all(): - async with asyncio.TaskGroup() as tg: - for ep in endpoints: - tg.create_task(Server(Config(**conf, **ep)).serve()) - - asyncio.run(serve_all()) +DEFAULT_PORT = TEMPLATE_DEFAULT_PORT def main(): parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.") parser.add_argument( - "endpoint", - nargs="?", - help=( - f"Endpoint (default: localhost:{DEFAULT_PORT}). " - "Forms: host:port | :port | [ipv6]:port | ip | host | unix:/path.sock" - ), + "-l", + "--listen", + action="append", + help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."), ) args = parser.parse_args() - proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1") - try: - run_server(parse_endpoint(args.endpoint, DEFAULT_PORT), proxy=proxy) - except KeyboardInterrupt: - pass + server.run( + "MODULE_NAME.APP_MODULE:APP_VAR", + listen=args.listen, + default_port=DEFAULT_PORT, + ) if __name__ == "__main__": diff --git a/template/scripts/devserver.py b/template/scripts/devserver.py index 253e3d2..9f33f89 100755 --- a/template/scripts/devserver.py +++ b/template/scripts/devserver.py @@ -9,6 +9,8 @@ import os import sys from pathlib import Path +from fastapi_vue import server + # 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"))) from devutil import ( # type: ignore @@ -20,6 +22,9 @@ from devutil import ( # type: ignore setup_vite, ) +DEFAULT_VITE_PORT = TEMPLATE_VITE_PORT +DEFAULT_DEV_PORT = TEMPLATE_DEV_PORT + async def run_devserver(frontend: str, backend: str) -> None: reporoot = Path(__file__).parent.parent @@ -28,8 +33,10 @@ async def run_devserver(frontend: str, backend: str) -> None: logger.warning("Frontend source not found at %s", front) raise SystemExit(1) - viteurl, npm_install, vite = setup_vite(frontend) - backurl, fastapi = setup_fastapi(backend, "MODULE_NAME.APP_MODULE:APP_VAR") + viteurl, npm_install, vite = setup_vite(frontend, DEFAULT_VITE_PORT) + backurl, module, backend_config = setup_fastapi( + backend, "MODULE_NAME.APP_MODULE:APP_VAR", DEFAULT_DEV_PORT + ) # Tell the everyone where the frontend and backend are (vite proxy, etc) os.environ["FASTAPI_VUE_FRONTEND_URL"] = viteurl @@ -38,13 +45,10 @@ async def run_devserver(frontend: str, backend: str) -> None: async with ProcessGroup() as pg: npm_i = await pg.spawn(*npm_install, cwd=front) await check_ports_free(viteurl, backurl) - await pg.spawn( - *fastapi, - "--reload", - "--reload-dir=MODULE_NAME", # Don't reload on frontend changes - "--forwarded-allow-ips=*", - cwd=reporoot, - ) + + # Run backend in a thread (server.run is blocking) + loop = asyncio.get_event_loop() + loop.run_in_executor(None, lambda: server.run(module, **backend_config)) # Wait for both install and backend to be ready await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py")) @@ -63,12 +67,12 @@ def main(): "frontend", nargs="?", metavar="host:port", - help="Vite frontend endpoint (default: localhost:5173)", + help=f"Vite frontend endpoint (default: localhost:{DEFAULT_VITE_PORT})", ) parser.add_argument( "--backend", metavar="host:port", - help="FastAPI backend endpoint (default: localhost:5180)", + help=f"FastAPI backend endpoint (default: localhost:{DEFAULT_DEV_PORT})", ) args = parser.parse_args() with contextlib.suppress(KeyboardInterrupt): diff --git a/template/scripts/fastapi-vue/devutil.py b/template/scripts/fastapi-vue/devutil.py index 82af050..3eb1564 100644 --- a/template/scripts/fastapi-vue/devutil.py +++ b/template/scripts/fastapi-vue/devutil.py @@ -11,9 +11,6 @@ import httpx from buildutil import find_dev_tool, find_install_tool, logger from fastapi_vue.hostutil import parse_endpoint -DEFAULT_VITE_PORT = 5173 -DEFAULT_BACKEND_PORT = 5180 - class ProcessGroup: """Manage async subprocesses with automatic cleanup, like TaskGroup for processes.""" @@ -134,13 +131,15 @@ async def ready(url: str, path: str = "") -> None: await asyncio.sleep(0.1) -def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]: +def setup_vite( + endpoint: str, default_port: int = 5173 +) -> tuple[str, list[str], list[str]]: """Parse frontend endpoint and build commands. Returns (url, install_cmd, dev_cmd). Raises SystemExit(1) on invalid config. """ - endpoints = parse_endpoint(endpoint, DEFAULT_VITE_PORT) + endpoints = parse_endpoint(endpoint, default_port) if "uds" in endpoints[0]: logger.warning("Unix sockets not supported with vite devserver") @@ -153,18 +152,17 @@ def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]: dev_cmd = find_dev_tool() if host != "localhost": dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}") - if port != 5173: - dev_cmd.append(f"--port={port}") + dev_cmd.append(f"--port={port}") return f"http://{host}:{port}", install_cmd, dev_cmd def setup_fastapi( - endpoint: str, module: str, default_port: int = DEFAULT_BACKEND_PORT -) -> tuple[str, list[str]]: - """Parse backend endpoint and build fastapi dev command. + endpoint: str, module: str, default_port: int = 8000 +) -> tuple[str, str, dict]: + """Parse backend endpoint and build server.run() config. - Returns (url, cmd). + Returns (url, module, config_dict). Raises SystemExit(1) on invalid config. """ endpoints = parse_endpoint(endpoint, default_port) @@ -176,14 +174,10 @@ def setup_fastapi( host = endpoints[0]["host"] port = endpoints[0]["port"] - cmd = [ - "fastapi", - "dev", - "--entrypoint", - module, - "--host", - host, - "--port", - str(port), - ] - return f"http://{host}:{port}", cmd + config = { + "listen": f"{host}:{port}", + "reload": True, + "reload_dirs": [module.split(".")[0]], # Don't reload on frontend changes + "forwarded_allow_ips": "*", + } + return f"http://{host}:{port}", module, config