diff --git a/fastapi_vue_setup.py b/fastapi_vue_setup.py index 983a8c1..c7340c0 100644 --- a/fastapi_vue_setup.py +++ b/fastapi_vue_setup.py @@ -23,6 +23,10 @@ import tomlkit # Template directory TEMPLATE_DIR = Path(__file__).parent / "template" +# Marker comment indicating file can be auto-upgraded +# Users should remove this line to prevent automatic updates +UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup" + # pyproject.toml additions for patched projects PYPROJECT_ADDITIONS = { "tool": { @@ -471,14 +475,23 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo return True +# Track .new.py files written during setup (for merge notification) +_new_files_written: list[tuple[Path, Path]] = [] + + def write_file( path: Path, content: str, overwrite: bool = True, dry_run: bool = False, executable: bool = False, + fallback_path: Path | None = None, ) -> bool: - """Write content to a file, handling existing files and dry-run.""" + """Write content to a file, handling existing files and dry-run. + + If fallback_path is provided and the file exists without the upgrade marker, + the content will be written to fallback_path instead of being skipped. + """ exists = path.exists() if exists and not overwrite: print(f"ℹ️ Skipping {path} (exists)") @@ -491,6 +504,16 @@ def write_file( print(f"✔️ {path} (already up to date)") return False + # If overwrite requested but file doesn't have upgrade marker + if overwrite and UPGRADE_MARKER not in existing_content: + if fallback_path is not None: + # Write to fallback path instead + return _write_fallback_file( + path, fallback_path, content, dry_run, executable + ) + print(f"ℹ️ Skipping {path} (customized by user)") + return False + if dry_run: action = "overwrite" if exists else "create" print(f"[DRY RUN] Would {action} {path}") @@ -505,6 +528,34 @@ def write_file( return True +def _write_fallback_file( + original_path: Path, + fallback_path: Path, + content: str, + dry_run: bool, + executable: bool, +) -> bool: + """Write content to a fallback .new.py file when original can't be overwritten.""" + # Check if fallback already has same content + if fallback_path.exists(): + if fallback_path.read_text() == content: + print(f"✔️ {fallback_path} (already up to date)") + return False + + if dry_run: + print(f"[DRY RUN] Would create {fallback_path} (original customized by user)") + _new_files_written.append((fallback_path, original_path)) + return True + + fallback_path.parent.mkdir(parents=True, exist_ok=True) + fallback_path.write_text(content) + if executable and sys.platform != "win32": + fallback_path.chmod(fallback_path.stat().st_mode | 0o111) + print(f"✅ Created {fallback_path} (original customized by user)") + _new_files_written.append((fallback_path, original_path)) + return True + + def merge_pyproject( data: tomlkit.TOMLDocument, additions: dict, module_name: str ) -> tomlkit.TOMLDocument: @@ -768,27 +819,40 @@ def cmd_setup(args: argparse.Namespace) -> int: module_dir.mkdir(parents=True) # === Install scripts (always update our own scripts) === - script_files = [ + # util.py and build-frontend.py are internal and always overwritten + # devserver.py can be customized, so use fallback if needed + internal_script_files = [ (fastapi_vue_scripts / "util.py", "scripts/fastapi-vue/util.py"), ( fastapi_vue_scripts / "build-frontend.py", "scripts/fastapi-vue/build-frontend.py", ), - (scripts_dir / "devserver.py", "scripts/devserver.py"), ] - for dest_path, template_path in script_files: + for dest_path, template_path in internal_script_files: template = load_template(template_path) content = render_template(template, **tpl_vars) - is_executable = dest_path.name == "devserver.py" write_file( dest_path, content, overwrite=True, dry_run=dry_run, - executable=is_executable, ) + # devserver.py - use fallback if customized by user + devserver_path = scripts_dir / "devserver.py" + devserver_fallback = scripts_dir / "devserver.new.py" + template = load_template("scripts/devserver.py") + content = render_template(template, **tpl_vars) + write_file( + devserver_path, + content, + overwrite=True, + dry_run=dry_run, + executable=True, + fallback_path=devserver_fallback, + ) + # === Handle app module === if app_file: # Existing app: patch with import, route, and try to patch lifespan @@ -810,28 +874,21 @@ def cmd_setup(args: argparse.Namespace) -> int: # === Handle __main__.py === main_file = module_dir / "__main__.py" - alt_main_file = module_dir / "__main__.fastapi-vue.py" + main_fallback = module_dir / "__main__.new.py" template = load_template("backend/__main__.py") main_content = render_template(template, **tpl_vars) - used_alt_main = False - if not main_file.exists(): - # No __main__.py, create it - write_file(main_file, main_content, overwrite=False, dry_run=dry_run) - else: - # Check if existing __main__.py is ours or compatible - existing_content = main_file.read_text() - if "fastapi_vue" in existing_content or existing_content == main_content: - # Already ours or matches - skip or update - if existing_content != main_content: - write_file(main_file, main_content, overwrite=True, dry_run=dry_run) - else: - print(f"✔️ {main_file} (already up to date)") - else: - # User has their own __main__.py, install as alternate filename - print(f"⚠️ {main_file} exists and is not ours, using alternate name") - write_file(alt_main_file, main_content, overwrite=True, dry_run=dry_run) - used_alt_main = True + # Use write_file with fallback - it handles all cases: + # - File doesn't exist: create it + # - File exists with marker: update it + # - File exists without marker: write to fallback + write_file( + main_file, + main_content, + overwrite=True, + dry_run=dry_run, + fallback_path=main_fallback, + ) # === Update vite.config.js/ts === frontend_dir = project_dir / "frontend" @@ -869,16 +926,7 @@ def cmd_setup(args: argparse.Namespace) -> int: if "scripts" not in updated["project"] or not updated["project"]["scripts"]: updated["project"]["scripts"] = tomlkit.table() script_name = module_name.replace("_", "-") - if used_alt_main: - # Use the alternate module name with dot notation - main_module = "__main__.fastapi-vue" - updated["project"]["scripts"][script_name] = ( - f"{module_name}.{main_module}:main" - ) - else: - updated["project"]["scripts"][script_name] = ( - f"{module_name}.__main__:main" - ) + updated["project"]["scripts"][script_name] = f"{module_name}.__main__:main" # Check if content actually changed new_content = tomlkit.dumps(updated) @@ -946,6 +994,19 @@ def cmd_setup(args: argparse.Namespace) -> int: ) print(message) + # Show merge note if any .new.py files were written + if _new_files_written: + print() + print( + "⚠️ Note: Some files could not be auto-upgraded because you customized them." + ) + print(" Please manually merge the following files:") + for new_file, original_file in _new_files_written: + print(f" • {new_file.name} → {original_file.name}") + print() + # Clear the list for potential subsequent runs + _new_files_written.clear() + return 0 diff --git a/template/backend/__main__.py b/template/backend/__main__.py index 8c9d9c6..f5493ad 100644 --- a/template/backend/__main__.py +++ b/template/backend/__main__.py @@ -1,3 +1,4 @@ +# auto-upgrade@fastapi-vue-setup - remove this if you modify this file import argparse import asyncio import os diff --git a/template/scripts/devserver.py b/template/scripts/devserver.py index f2ed0c3..258e5d0 100755 --- a/template/scripts/devserver.py +++ b/template/scripts/devserver.py @@ -1,4 +1,5 @@ #!/usr/bin/env -S uv run +# auto-upgrade@fastapi-vue-setup - remove this if you modify this file """Run Vite development server for frontend and FastAPI backend with auto-reload. Usage: @@ -91,13 +92,15 @@ def resolve_frontend_tools( return install_cmd, dev_cmd, name -async def wait_for_backend(backend_host: str, backend_port: int): +async def wait_for_backend(host: str, port: int): """Wait for the backend to be ready by polling the health endpoint.""" max_attempts = 50 + url = f"http://{host}:{port}" + async with httpx.AsyncClient() as client: for attempt in range(max_attempts): try: - await client.get(f"http://{backend_host}:{backend_port}", timeout=1.0) + await client.get(url, timeout=1.0) stderr.write("✓ Backend ready!\n") return True except httpx.RequestError: @@ -129,18 +132,27 @@ async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> Non async def run_devserver( vite_port: int, all_ifaces: bool, - backend_ep: dict, + backend_host: str, + backend_port: int, ) -> None: """Run the development server with install, backend, and frontend.""" install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port, all_ifaces) - backend_host = backend_ep["host"] - backend_port = backend_ep["port"] - # Tell the backend where the Vite dev server is os.environ["FASTAPI_VUE_FRONTEND_URL"] = f"http://localhost:{vite_port}" # Tell Vite where the backend is (for proxying /api requests) os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}" + + backend_cmd = [ + "uvicorn", + "MODULE_NAME.app:app", + "--host", + backend_host, + "--port", + str(backend_port), + "--reload", + ] + cwd = str(Path(__file__).parent.parent) frontend_cwd = str(FRONTEND_PATH) @@ -158,15 +170,6 @@ async def run_devserver( await asyncio.sleep(0.1) # Start backend (concurrent with install) - backend_cmd = [ - "uvicorn", - "MODULE_NAME.app:app", - "--host", - backend_host, - "--port", - str(backend_port), - "--reload", - ] stderr.write(f">>> {' '.join(backend_cmd)}\n") backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd) @@ -255,14 +258,19 @@ def main(): # Vite doesn't support unix sockets if "uds" in vite_endpoints[0]: - stderr.write("┃ ⚠️ Vite does not support unix sockets\n") + stderr.write("┃ ⚠️ Unix sockets not supported for frontend\n") + raise SystemExit(1) + if "uds" in backend_endpoints[0]: + stderr.write("┃ ⚠️ Unix sockets not supported for backend\n") raise SystemExit(1) vite_port = vite_endpoints[0]["port"] all_ifaces = len(vite_endpoints) > 1 + backend_host = backend_endpoints[0]["host"] + backend_port = backend_endpoints[0]["port"] with contextlib.suppress(KeyboardInterrupt): - asyncio.run(run_devserver(vite_port, all_ifaces, backend_endpoints[0])) + asyncio.run(run_devserver(vite_port, all_ifaces, backend_host, backend_port)) if __name__ == "__main__":