diff --git a/fastapi_vue_setup.py b/fastapi_vue_setup.py index 785cd59..24d08fe 100644 --- a/fastapi_vue_setup.py +++ b/fastapi_vue_setup.py @@ -12,6 +12,7 @@ Options: """ import argparse +import ast import os import re import shutil @@ -22,9 +23,49 @@ from textwrap import indent import tomlkit +# Track Python files written/patched for ruff formatting +_python_files_to_format: list[Path] = [] + # Template directory TEMPLATE_DIR = Path(__file__).parent / "template" + +def ruff_sort_imports(files: list[Path], dry_run: bool = False) -> None: + """Run ruff to sort imports in the given Python files.""" + if not files: + return + py_files = [str(f) for f in files if f.suffix == ".py" and f.exists()] + if not py_files: + return + if dry_run: + print(f"[DRY RUN] Would run ruff import sorting on {len(py_files)} files") + return + print("🔧 Ruff isort on modified files") + subprocess.run( + ["ruff", "check", "--select", "I", "--fix", *py_files], + stdout=subprocess.DEVNULL, + ) + + +def uv_add_packages( + packages: list[str], *, cwd: Path, group: str | None = None, dry_run: bool = False +) -> None: + """Add packages using uv.""" + cmd = ["uv", "add", "-q", "-U"] + if group: + cmd.extend(["--group", group]) + else: + cmd.append("--no-sync") + cmd.extend(packages) + if dry_run: + print(f"[DRY RUN] Would run: {' '.join(cmd)}") + return + result = subprocess.run(cmd, cwd=cwd, check=False) + if result.returncode != 0: + label = f" ({group})" if group else "" + print(f"⚠️ Failed to add{label} dependencies") + + # Default ports: (default, vite, dev) # If vite == dev, dev is incremented by 100 DEFAULT_PORTS = (3100, 3100, 3200) @@ -153,6 +194,24 @@ def parse_ports(ports_str: str | None) -> tuple[int, int, int]: return default, vite, dev +def find_import_insertion_line(source: str) -> int: + """Find line number (1-based) for inserting imports, after shebang/docstring.""" + try: + tree = ast.parse(source) + except SyntaxError: + return 2 if source.startswith("#!") else 1 + # Find first import, or end of docstring if no imports + for node in tree.body: + if isinstance(node, (ast.Import, ast.ImportFrom)): + return node.lineno + if not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)): + break # Non-import, non-docstring statement + # No imports found - insert after docstring or at line 1 + if tree.body and isinstance(tree.body[0], ast.Expr): + return tree.body[0].end_lineno + 1 + return 2 if source.startswith("#!") else 1 + + def extract_existing_ports(project_dir: Path) -> tuple[int, int, int] | None: """Extract existing port configuration from project files. @@ -421,7 +480,7 @@ def patch_app_file( ) -> bool: """Patch an existing app.py with frontend integration. - Inserts import and Frontend instantiation after imports, route at bottom, + Inserts imports at top (ruff will sort them), route at bottom, and tries to patch lifespan with frontend.load(). Returns True if patched, False if already patched or failed. @@ -431,62 +490,94 @@ def patch_app_file( return False original_content = path.read_text("UTF-8") - marker = "from fastapi_vue import Frontend" + content = original_content - if marker in original_content: + # Check what's already patched + has_frontend = "from fastapi_vue import Frontend" in content + has_devmode = f"from {module_name}.__main__ import DEVMODE" in content + has_debug_arg = re.search(r"FastAPI\s*\([^)]*debug\s*=", content) is not None + + if has_frontend and has_devmode and has_debug_arg: print(f"✔️ {path} (already patched)") return False - # Find where to insert the import (after other imports) - lines = original_content.split("\n") - import_line = "from fastapi_vue import Frontend" - route_line = f'frontend.route({app_var}, "/")' - # Find last import line and check if pathlib is imported - last_import_idx = 0 - has_pathlib = False - for i, line in enumerate(lines): - stripped = line.strip() - if stripped.startswith("import ") or stripped.startswith("from "): - last_import_idx = i - if "pathlib" in stripped or "from pathlib" in stripped: - has_pathlib = True - elif stripped and not stripped.startswith("#") and last_import_idx > 0: - # Stop at first non-import, non-comment, non-empty line after imports - break + # Add missing imports (using AST to find correct insertion point) + imports = [] + if not has_frontend: + imports.extend(["from pathlib import Path", "from fastapi_vue import Frontend"]) + if not has_devmode: + imports.append(f"from {module_name}.__main__ import DEVMODE") + if imports: + insert_line = find_import_insertion_line(content) + lines = content.splitlines(keepends=True) + # Convert to 0-based index + insert_idx = insert_line - 1 + import_text = "\n".join(imports) + "\n" + if insert_idx >= len(lines): + # Append at end + content = content.rstrip("\n") + "\n" + import_text + else: + # Insert at the found position + content = ( + "".join(lines[:insert_idx]) + import_text + "".join(lines[insert_idx:]) + ) - # Check if we found imports to insert after - if last_import_idx == 0 and not lines[0].strip().startswith(("import ", "from ")): - print(f"⚠️ Skipping {path} (no imports found to patch)") - return False + # Insert FRONTEND_BLOCK after last import (only if Frontend wasn't already there) + if not has_frontend: + lines = content.split("\n") + last_import_idx = 0 + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("import ") or stripped.startswith("from "): + last_import_idx = i + elif stripped and not stripped.startswith("#") and last_import_idx > 0: + break + lines.insert(last_import_idx + 1, FRONTEND_BLOCK) - # Insert imports after last import, then frontend instantiation - if not has_pathlib: - lines.insert(last_import_idx + 1, "from pathlib import Path") - last_import_idx += 1 - lines.insert(last_import_idx + 1, import_line) - lines.insert(last_import_idx + 2, FRONTEND_BLOCK) + # Append route at end + lines.append("") + lines.append( + "# Serve the Vue frontend (needs to be last if SPA catch-all is used)" + ) + lines.append(route_line) + content = "\n".join(lines) - # Append route at end - lines.append("") - lines.append("# Serve the Vue frontend (needs to be last if SPA catch-all is used)") - lines.append(route_line) - content = "\n".join(lines) + # Try to patch FastAPI() call with debug=DEVMODE if no debug arg exists + if not has_debug_arg: + fastapi_pattern = r"(\w+\s*=\s*FastAPI\s*\()([^)]*)\)" + for match in re.finditer(fastapi_pattern, content, re.DOTALL): + args = match.group(2) + if "debug" not in args: + # Add debug=DEVMODE as first argument + if args.strip(): + new_args = f"debug=DEVMODE, {args}" + else: + new_args = "debug=DEVMODE" + content = ( + content[: match.start()] + + match.group(1) + + new_args + + ")" + + content[match.end() :] + ) + break # Only patch first FastAPI() call # Try to patch lifespan function - insert await frontend.load() before yield - lifespan_patched = False + lifespan_patched = "await frontend.load()" in content # Look for yield inside an async def lifespan function # Find the yield statement and insert before it - yield_pattern = r"^([ \t]+)(yield\b)" - yield_match = re.search(yield_pattern, content, re.MULTILINE) - if yield_match: - indent = yield_match.group(1) - insert_pos = yield_match.start() - load_code = f"{indent}await frontend.load()\n" - content = content[:insert_pos] + load_code + content[insert_pos:] - lifespan_patched = True + if not lifespan_patched: + yield_pattern = r"^([ \t]+)(yield\b)" + yield_match = re.search(yield_pattern, content, re.MULTILINE) + if yield_match: + ws = yield_match.group(1) + insert_pos = yield_match.start() + load_code = f"{ws}await frontend.load()\n" + content = content[:insert_pos] + load_code + content[insert_pos:] + lifespan_patched = True # Check if content actually changed if content == original_content: @@ -498,6 +589,7 @@ def patch_app_file( return True path.write_text(content, "UTF-8", newline="\n") + _python_files_to_format.append(path) print(f"✅ Patched {path}") if not lifespan_patched: @@ -743,6 +835,8 @@ def write_file( path.write_text(content, "UTF-8", newline="\n") if executable and sys.platform != "win32": path.chmod(path.stat().st_mode | 0o111) + if path.suffix == ".py": + _python_files_to_format.append(path) action = "Updated" if exists else "Created" print(f"✅ {action} {path}") return True @@ -771,6 +865,8 @@ def _write_fallback_file( fallback_path.write_text(content, "UTF-8", newline="\n") if executable and sys.platform != "win32": fallback_path.chmod(fallback_path.stat().st_mode | 0o111) + if fallback_path.suffix == ".py": + _python_files_to_format.append(fallback_path) print(f"✅ Created {fallback_path} (original customized by user)") _new_files_written.append((fallback_path, original_path)) return True @@ -1047,6 +1143,8 @@ def cmd_setup(args: argparse.Namespace) -> int: "TEMPLATE_DEFAULT_PORT": str(default_port), "TEMPLATE_VITE_PORT": str(vite_port), "TEMPLATE_DEV_PORT": str(dev_port), + "ENVPREFIX": module_name.upper(), + "PROJECT_CLI": module_name, } module_dir = project_dir / module_name @@ -1239,27 +1337,13 @@ 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_dev = ["uv", "add", "-q", "-U", "--group", "dev", "httpx"] - if dry_run: - print(f"[DRY RUN] Would run: {' '.join(uv_add_main)}") - print(f"[DRY RUN] Would run: {' '.join(uv_add_dev)}") - else: - print("📦 Adding dependencies...") - result = subprocess.run(uv_add_main, cwd=project_dir, check=False) - if result.returncode != 0: - print("⚠️ Failed to add main dependencies") - result = subprocess.run(uv_add_dev, cwd=project_dir, check=False) - if result.returncode != 0: - print("⚠️ Failed to add dev dependencies") + ruff_sort_imports(_python_files_to_format, dry_run=dry_run) + if not dry_run: + print("📦 Dependencies") + uv_add_packages( + ["fastapi[standard]", "fastapi-vue"], cwd=project_dir, dry_run=dry_run + ) + uv_add_packages(["httpx"], cwd=project_dir, group="dev", dry_run=dry_run) print() print("=" * 60) diff --git a/pyproject.toml b/pyproject.toml index c42c750..5902b81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ description = "Tool to create or patch FastAPI+Vue projects with integrated buil readme = "README.md" requires-python = ">=3.11" dependencies = [ + "ruff>=0.14.13", "tomlkit>=0.12.0", ] @@ -26,4 +27,7 @@ source = "vcs" include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"] [dependency-groups] -dev = ["ruff"] +dev = ["ruff", "fastapi-vue"] + +[tool.uv.sources] +fastapi-vue = { path = "fastapi-vue", editable = true } diff --git a/template/backend/__main__.py b/template/backend/__main__.py index cb0f2d7..78015a6 100644 --- a/template/backend/__main__.py +++ b/template/backend/__main__.py @@ -1,9 +1,11 @@ # auto-upgrade@fastapi-vue-setup - remove this if you modify this file import argparse +import os from fastapi_vue import server DEFAULT_PORT = TEMPLATE_DEFAULT_PORT +DEVMODE = bool(os.getenv("ENVPREFIX_FRONTEND_URL")) def main(): @@ -19,6 +21,7 @@ def main(): "MODULE_NAME.APP_MODULE:APP_VAR", listen=args.listen, default_port=DEFAULT_PORT, + reload=DEVMODE, ) diff --git a/template/backend/app.py b/template/backend/app.py index 9cd084a..2ad729b 100644 --- a/template/backend/app.py +++ b/template/backend/app.py @@ -3,9 +3,10 @@ from pathlib import Path from fastapi import FastAPI from fastapi_vue import Frontend +from MODULE_NAME.__main__ import DEVMODE # Vue Frontend static files -frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"]) +frontend = Frontend(Path(__file__).with_name("frontend-build")) @asynccontextmanager @@ -15,7 +16,7 @@ async def lifespan(app: FastAPI): yield -app = FastAPI(title="PROJECT_TITLE", lifespan=lifespan) +app = FastAPI(title="PROJECT_TITLE", debug=DEVMODE, lifespan=lifespan) # Add API routes here... diff --git a/template/frontend/vite-plugin-fastapi.js b/template/frontend/vite-plugin-fastapi.js index d2b07ca..f719bd5 100644 --- a/template/frontend/vite-plugin-fastapi.js +++ b/template/frontend/vite-plugin-fastapi.js @@ -1,17 +1,18 @@ /** * FastAPI-Vue Vite Plugin + * auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin * * Configures Vite for FastAPI backend integration: * - Proxies /api/* requests to the FastAPI backend * - Builds to the Python module's frontend-build directory * - * Environment variables (with defaults): - * FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying + * Options: + * paths - Array of paths to proxy (default: ["/api"]) */ -const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180" - export default function fastapiVue({ paths = ["/api"] } = {}) { + const backendUrl = process.env.ENVPREFIX_BACKEND_URL || "http://localhost:TEMPLATE_DEFAULT_PORT" + // Build proxy configuration for each path const proxy = {} for (const path of paths) { @@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) { } return { - name: "fastapi-vite", + name: "vite-plugin-fastapi-MODULE_NAME", config: () => ({ server: { proxy }, build: { diff --git a/template/scripts/devserver.py b/template/scripts/devserver.py index 531e772..a66ebb3 100755 --- a/template/scripts/devserver.py +++ b/template/scripts/devserver.py @@ -16,7 +16,6 @@ from devutil import ( # type: ignore check_ports_free, logger, ready, - setup_fastapi, setup_vite, ) @@ -24,7 +23,9 @@ DEFAULT_VITE_PORT = TEMPLATE_VITE_PORT DEFAULT_DEV_PORT = TEMPLATE_DEV_PORT -async def run_devserver(frontend: str, backend: str) -> None: +async def run_devserver( + frontend: str, backend: str, extra_args: list[str] | None = None +) -> None: reporoot = Path(__file__).parent.parent front = reporoot / "frontend" if not (front / "package.json").exists(): @@ -32,18 +33,16 @@ async def run_devserver(frontend: str, backend: str) -> None: raise SystemExit(1) viteurl, npm_install, vite = setup_vite(frontend, DEFAULT_VITE_PORT) - backurl, uvicorn = setup_fastapi( - backend, "MODULE_NAME.APP_MODULE:APP_VAR", DEFAULT_DEV_PORT - ) + backurl, MODULE_NAME = setup_cli("PROJECT_CLI", backend, DEFAULT_DEV_PORT) # Tell the everyone where the frontend and backend are (vite proxy, etc) - os.environ["FASTAPI_VUE_FRONTEND_URL"] = viteurl - os.environ["FASTAPI_VUE_BACKEND_URL"] = backurl + os.environ["ENVPREFIX_FRONTEND_URL"] = viteurl + os.environ["ENVPREFIX_BACKEND_URL"] = backurl async with ProcessGroup() as pg: npm_i = await pg.spawn(*npm_install, cwd=front) await check_ports_free(viteurl, backurl) - await pg.spawn(*uvicorn) + await pg.spawn(*MODULE_NAME, *(extra_args or [])) await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py")) await pg.spawn(*vite, cwd=front) @@ -65,15 +64,13 @@ def main(): metavar="host:port", help=f"FastAPI backend endpoint (default: localhost:{DEFAULT_DEV_PORT})", ) - args = parser.parse_args() + args, extra_args = parser.parse_known_args() with suppress(KeyboardInterrupt): - asyncio.run(run_devserver(args.frontend, args.backend)) + asyncio.run(run_devserver(args.frontend, args.backend, extra_args)) HELP_EPILOG = """ - scripts/devserver.py # Default ports on localhost - scripts/devserver.py 3000 # Vite on localhost:3000 - scripts/devserver.py :3000 --backend 8000 # *:3000, localhost:8000 + scripts/devserver.py [args to PROJECT_CLI] 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). diff --git a/template/scripts/fastapi-vue/devutil.py b/template/scripts/fastapi-vue/devutil.py index 2d2f20d..811781c 100644 --- a/template/scripts/fastapi-vue/devutil.py +++ b/template/scripts/fastapi-vue/devutil.py @@ -188,3 +188,29 @@ def setup_fastapi( "--forwarded-allow-ips=*", ] return f"http://{host}:{port}", cmd + + +def setup_cli( + cli: str, endpoint: str, default_port: int = 8000 +) -> tuple[str, list[str]]: + """Parse backend endpoint and build CLI command. + + Returns (url, cli_cmd). + Raises SystemExit(1) on invalid config. + """ + endpoints = parse_endpoint(endpoint, default_port) + + if "uds" in endpoints[0]: + logger.warning("Unix sockets not supported with vite devserver") + raise SystemExit(1) + + host = endpoints[0]["host"] + port = endpoints[0]["port"] + + cmd = [ + sys.executable, + "-m", + cli, + f"--listen={host}:{port}", + ] + return f"http://{host}:{port}", cmd