From a28c4ec149a42236f4100c52241f728aab4e36fd Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 17 Jan 2026 01:53:02 +0000 Subject: [PATCH] Initial commit --- .gitignore | 5 + README.md | 124 +++ fastapi_vue_setup.py | 817 ++++++++++++++++++ pyproject.toml | 29 + template/backend/__init__.py | 3 + template/backend/__main__.py | 32 + template/backend/app.py | 40 + template/frontend/vite-plugin-fastapi.js | 37 + template/scripts/devserver.py | 268 ++++++ .../scripts/fastapi-vue/build-frontend.py | 34 + template/scripts/fastapi-vue/util.py | 86 ++ 11 files changed, 1475 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 fastapi_vue_setup.py create mode 100644 pyproject.toml create mode 100644 template/backend/__init__.py create mode 100644 template/backend/__main__.py create mode 100644 template/backend/app.py create mode 100644 template/frontend/vite-plugin-fastapi.js create mode 100644 template/scripts/devserver.py create mode 100644 template/scripts/fastapi-vue/build-frontend.py create mode 100644 template/scripts/fastapi-vue/util.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16839bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.* +!.gitignore +*.lock +__pycache__/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6887f3 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# fastapi-vue-setup + +Tool to create or patch FastAPI project with a Vue frontend, with integrated build and development systems. Only building the package needs JS runtime and Vue. Additionally, a devmode setup using Vite dev server with hot auto reloads is available via the scripts/devserver.py script (only intended to be used on the source repo, not included in installed package). + +## Features + +- **No JavaScript**: Your Python package can be installed and used without any JS runtime +- **Create new projects** with `uv init` + `create-vue` (interactive) +- **Patch existing projects** with build hooks and dev server scripts +- **Integrated build system**: Vue frontend builds into Python package during `uv build` +- **Development server**: Single command runs Vite + FastAPI with hot-reload +- **Optimized static serving**: zstd compression, ETag caching, SPA support + +## Installation + +Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then: + +```sh +uv tool install fastapi-vue-setup +fastapi-vue-setup --help +``` + +Or run directly: + +```sh +uvx fastapi-vue-setup my-app +``` + +## Usage + +### Create a new project + +```sh +fastapi-vue-setup new my-app +``` + +This will: + +1. Run `uv init my-app` +2. Run `npm create vue@latest frontend` (interactive) +3. Patch the project with FastAPI integration + +### Patch an existing project + +```bash +fastapi-vue-setup patch /path/to/project +``` + +Options: + +- `--module-name NAME`: Python module name (auto-detected from pyproject.toml) +- `--vite-port PORT`: Vite dev server port (default: 5173) +- `--backend-port PORT`: Backend API port in dev mode (default: 5174) +- `--prod-port PORT`: Production server port (default: 8000) +- `--force`: Overwrite existing files +- `--dry-run`: Preview changes without modifying files + +### Update an existing project + +```bash +fastapi-vue-setup update /path/to/project +``` + +Same as `patch --force` - overwrites template files with latest versions. + +## Port Configuration + +The tool uses three distinct ports: + +| Port | Purpose | Used by | +| ---- | --------------------- | ------------------------------ | +| 5173 | Vite dev server (HMR) | `npm run dev` via devserver.py | +| 5174 | FastAPI in dev mode | uvicorn via devserver.py | +| 8000 | Production server | `uv run my-app` | + +In development, you access the app at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5174. + +In production, FastAPI serves both the API and static files from the same port (8000). + +## Project Structure + +After patching, your project will have: + +``` +my-app/ +├── frontend/ # Vue.js application +│ ├── src/ +│ ├── vite.config.ts # Builds to ../my_app/frontend-build +│ └── package.json +├── my_app/ # Python package +│ ├── __init__.py +│ ├── __main__.py # CLI entry point +│ ├── app.py # FastAPI application +│ └── frontend-build/ # Built frontend (gitignored) +├── scripts/ +│ ├── devserver.py # Development server +│ └── fastapi-vue/ # Build utilities +└── pyproject.toml # Project configuration +``` + +The script finds your existing fastpi app module (even if not named app.py) and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection. + +## Development Workflow + +```bash +# Start dev server (runs both Vite and FastAPI) +uv run scripts/devserver.py + +# Build for production +uv build + +# Run production server +uv run my-app +``` + +## Static File Serving + +The `Frontend` class provides: + +- **Automatic zstd compression** at level 18 +- **Smart caching**: Content-hashed assets get `immutable` cache headers +- **ETag support**: 304 Not Modified responses for cached content +- **SPA routing**: Falls back to index.html for client-side routes +- **Favicon handling**: Automatic /favicon.ico from hashed assets diff --git a/fastapi_vue_setup.py b/fastapi_vue_setup.py new file mode 100644 index 0000000..808b3f6 --- /dev/null +++ b/fastapi_vue_setup.py @@ -0,0 +1,817 @@ +"""FastAPI-Vue Integration Tool + +Create new FastAPI+Vue projects or patch existing ones with integrated build/dev systems. + +Usage: + fastapi-vue-setup [project-dir] Set up or update FastAPI+Vue integration + +Options: + --module-name NAME Python module name (auto-detected from pyproject.toml) + --vite-port PORT Vite dev server port (default: 5173) + --backend-port PORT Backend API port in dev mode (default: 5180) + --prod-port PORT Production server port (default: 5080) + --dry-run Show what would be done without making changes + +Port options update existing files when specified, allowing reconfiguration. +""" + +import argparse +import os +import re +import subprocess +import sys +import tomllib +from pathlib import Path + +import tomli_w + +# Port configuration (keeping them distinct) +DEFAULT_VITE_PORT = 5173 # Vite dev server (frontend HMR) +DEFAULT_BACKEND_PORT = 5180 # FastAPI in dev mode (Vite proxies /api here) +DEFAULT_PROD_PORT = 5080 # Production server (static files served by FastAPI) + +# Template directory +TEMPLATE_DIR = Path(__file__).parent / "template" + +# pyproject.toml additions for patched projects +PYPROJECT_ADDITIONS = { + "project": { + "dependencies": [ + "fastapi-vue>=0.1.0", + ], + }, + "tool": { + "hatch": { + "build": { + "artifacts": ["{{MODULE_NAME}}/frontend-build"], + "targets": { + "sdist": { + "hooks": { + "custom": {"path": "scripts/fastapi-vue/build-frontend.py"} + }, + } + }, + "only-packages": True, + } + } + }, +} + + +# ============================================================================= +# Utility functions +# ============================================================================= + + +def load_template(path: str) -> str: + """Load a template file from the template directory.""" + return (TEMPLATE_DIR / path).read_text() + + +def find_module_name(project_dir: Path) -> str | None: + """Auto-detect the Python module name from pyproject.toml.""" + pyproject = project_dir / "pyproject.toml" + if not pyproject.exists(): + return None + + with open(pyproject, "rb") as f: + data = tomllib.load(f) + + if "project" in data and "name" in data["project"]: + name = data["project"]["name"] + return name.replace("-", "_") + + return None + + +def find_fastapi_app(module_dir: Path) -> tuple[Path, str] | None: + """Find the FastAPI app in a module directory. + + Returns (file_path, app_variable_name) or None if not found. + """ + # Common app file names to check first + candidates = ["app.py", "main.py", "server.py", "api.py", "__init__.py"] + + # Check common names first + for name in candidates: + path = module_dir / name + if path.exists(): + result = _find_app_in_file(path) + if result: + return path, result + + # Then check all .py files + for path in module_dir.glob("*.py"): + if path.name not in candidates: + result = _find_app_in_file(path) + if result: + return path, result + + return None + + +def _find_app_in_file(path: Path) -> str | None: + """Find FastAPI app variable name in a file.""" + try: + content = path.read_text() + except Exception: + return None + + # Look for FastAPI() instantiation patterns + # Matches: app = FastAPI(...) or application = FastAPI(...) + pattern = r"^(\w+)\s*=\s*FastAPI\s*\(" + for match in re.finditer(pattern, content, re.MULTILINE): + return match.group(1) + + return None + + +def render_template(template: str, **kwargs) -> str: + """Simple template rendering with {{KEY}} placeholders.""" + result = template + for key, value in kwargs.items(): + result = result.replace(f"{{{{{key}}}}}", str(value)) + return result + + +def patch_app_file( + path: Path, module_name: str, app_var: str, dry_run: bool = False +) -> bool: + """Patch an existing app.py with frontend integration. + + Inserts import and Frontend instantiation after imports, route at bottom, + and tries to patch lifespan with frontend.load(). + + Returns True if patched, False if already patched or failed. + """ + if not path.exists(): + print(f"❌ Cannot patch {path} - file not found") + return False + + content = path.read_text() + marker = "from fastapi_vue import Frontend" + + if marker in content: + print(f"⚠️ Skipping {path} (already patched)") + return False + + if dry_run: + print(f"[DRY RUN] Would patch {path}") + return True + + # Find where to insert the import (after other imports) + lines = content.split("\n") + import_line = "from fastapi_vue import Frontend" + + # Frontend instantiation block - use module_name for the frontend-build path + frontend_block = """ +# Frontend static file server - configure options here +frontend = Frontend( + Path(__file__).parent / "frontend-build", + spa=True, + favicon="/assets/favicon", + cached=["/assets/"], +) +""" + 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 + + # 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(route_line) + content = "\n".join(lines) + + # Try to patch lifespan function + lifespan_patched = False + + # Look for async def lifespan pattern and insert after the opening (and docstring if present) + lifespan_pattern = r"(async\s+def\s+lifespan\s*\([^)]*\)\s*(?:->.*?)?:\s*\n)" + match = re.search(lifespan_pattern, content) + if match: + insert_pos = match.end() + rest = content[insert_pos:] + + # Detect indentation from the next line + indent_match = re.match(r"([ \t]*)", rest) + indent = ( + indent_match.group(1) if indent_match and indent_match.group(1) else " " + ) + + # Check if there's a docstring and skip past it + docstring_pattern = r'^([ \t]*)("""[\s\S]*?"""|\'\'\'\'[\s\S]*?\'\'\')\s*\n' + docstring_match = re.match(docstring_pattern, rest) + if docstring_match: + insert_pos += docstring_match.end() + + load_code = f"{indent}await frontend.load()\n" + content = content[:insert_pos] + load_code + content[insert_pos:] + lifespan_patched = True + + path.write_text(content) + print(f"✅ Patched {path}") + + if not lifespan_patched: + # Check if they're using deprecated on_event + if "@" + app_var + ".on_event" in content or f"@{app_var}.on_event" in content: + print() + print("⚠️ Your app uses the deprecated @app.on_event decorator.") + print(" Please migrate to the lifespan pattern and add:") + print(" await frontend.load()") + print() + else: + print() + print("⚠️ Could not find lifespan function to patch.") + print(" Add this to your app's lifespan function:") + print(" await frontend.load()") + print() + + return True + + +def patch_vite_config( + path: Path, + module_name: str, + backend_port: int, + vite_port: int, + dry_run: bool = False, +) -> bool: + """Patch an existing vite.config.js/ts by adding fastapi-vue plugin. + + This approach is cleaner than inline patching - we just add an import + and include the plugin in the plugins array. + """ + if not path.exists(): + print(f"❌ Cannot patch {path} - file not found") + return False + + content = path.read_text() + marker = "vite-plugin-fastapi" + + if marker in content: + print(f"⚠️ Skipping {path} (already patched)") + return False + + if dry_run: + print(f"[DRY RUN] Would patch {path}") + return True + + # Add import for the plugin at the top (after other imports) + import_line = f"import fastapiVue from './{marker}.js'" + + lines = content.split("\n") + new_lines = [] + import_inserted = False + + for i, line in enumerate(lines): + new_lines.append(line) + # Insert after the last import line before non-import content + if not import_inserted: + stripped = line.strip() + if stripped.startswith("import ") or stripped.startswith("from "): + # Check if next line is not an import + if i + 1 < len(lines): + next_stripped = lines[i + 1].strip() + if not next_stripped.startswith( + "import " + ) and not next_stripped.startswith("from "): + new_lines.append(import_line) + import_inserted = True + + if not import_inserted: + # No imports found, add at top + new_lines.insert(0, import_line) + + content = "\n".join(new_lines) + + # Add fastapiVue to plugins array + # Look for plugins: [ and add fastapiVue() as first entry + plugins_pattern = r"(plugins\s*:\s*\[)" + match = re.search(plugins_pattern, content) + if match: + insert_pos = match.end() + content = content[:insert_pos] + "\n fastapiVue()," + content[insert_pos:] + + path.write_text(content) + print(f"✅ Patched {path}") + return True + + +def write_file( + path: Path, content: str, overwrite: bool = True, dry_run: bool = False +) -> bool: + """Write content to a file, handling existing files and dry-run.""" + exists = path.exists() + if exists and not overwrite: + print(f"⚠️ Skipping {path} (exists)") + return False + + if dry_run: + action = "overwrite" if exists else "create" + print(f"[DRY RUN] Would {action} {path}") + return True + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + action = "Updated" if exists else "Created" + print(f"✅ {action} {path}") + return True + + +def merge_pyproject(data: dict, additions: dict, module_name: str) -> dict: + """Merge additions into pyproject.toml data.""" + result = data.copy() + + # Add dependencies + if "project" not in result: + result["project"] = {} + if "dependencies" not in result["project"]: + result["project"]["dependencies"] = [] + + for dep in additions["project"]["dependencies"]: + if not any( + dep.split("[")[0].split(">")[0] in d + for d in result["project"]["dependencies"] + ): + result["project"]["dependencies"].append(dep) + + # Add hatch build config + if "tool" not in result: + result["tool"] = {} + if "hatch" not in result["tool"]: + result["tool"]["hatch"] = {} + if "build" not in result["tool"]["hatch"]: + result["tool"]["hatch"]["build"] = {} + + hatch_build = additions["tool"]["hatch"]["build"] + result["tool"]["hatch"]["build"]["artifacts"] = [ + a.replace("{{MODULE_NAME}}", module_name) for a in hatch_build["artifacts"] + ] + result["tool"]["hatch"]["build"]["only-packages"] = hatch_build["only-packages"] + + if "targets" not in result["tool"]["hatch"]["build"]: + result["tool"]["hatch"]["build"]["targets"] = {} + + result["tool"]["hatch"]["build"]["targets"]["sdist"] = hatch_build["targets"][ + "sdist" + ] + + return result + + +# ============================================================================= +# Command implementations +# ============================================================================= + + +def find_js_runtime() -> tuple[str, str] | None: + """Find a JavaScript runtime from JS_RUNTIME env or auto-detect. + + Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun". + Returns None if no runtime is found. + """ + import shutil + + options = ["deno", "npm", "bun"] + + # Check for JS_RUNTIME environment variable + if js_runtime_env := os.environ.get("JS_RUNTIME"): + js_runtime = js_runtime_env + js_path = Path(js_runtime) + runtime_name = js_path.name + # Map node to npm + if runtime_name == "node": + runtime_name = "npm" + js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm" + for option in options: + if option == runtime_name or runtime_name.startswith(option): + tool = shutil.which(js_runtime) + if tool is None: + print(f"⚠️ JS_RUNTIME={js_runtime_env} not found") + return None + return tool, option + print(f"⚠️ JS_RUNTIME={js_runtime_env} not recognized") + return None + + # Auto-detect + for option in options: + if tool := shutil.which(option): + return tool, option + return None + + +def ensure_python_project(project_dir: Path, dry_run: bool = False) -> bool: + """Ensure pyproject.toml exists, run uv init if needed.""" + pyproject = project_dir / "pyproject.toml" + if pyproject.exists(): + return True + + if dry_run: + print(f"[DRY RUN] Would run: uv init {project_dir}") + return True + + print("📦 No pyproject.toml found, initializing Python project...") + print(">>> uv init") + result = subprocess.run(["uv", "init", str(project_dir)], check=False) + if result.returncode != 0: + print("❌ uv init failed") + return False + + # Remove hello.py if created + hello_py = project_dir / "hello.py" + if hello_py.exists(): + hello_py.unlink() + + # Remove .python-version if created + python_version = project_dir / ".python-version" + if python_version.exists(): + python_version.unlink() + + return True + + +def ensure_frontend(project_dir: Path, dry_run: bool = False) -> bool: + """Ensure frontend directory exists, run create-vue if needed.""" + frontend_dir = project_dir / "frontend" + if frontend_dir.exists(): + return True + + # Find JS runtime + runtime = find_js_runtime() + if runtime is None: + print("❌ No JavaScript runtime found (need deno, npm, or bun)") + return False + js_tool, js_name = runtime + + # Build the create command based on runtime + create_vue_commands = { + "deno": [js_tool, "run", "-A", "npm:create-vue@latest", "frontend"], + "npm": [js_tool, "create", "vue@latest", "frontend"], + "bun": [js_tool, "create", "vue@latest", "frontend"], + } + create_cmd = create_vue_commands[js_name] + + if dry_run: + print(f"[DRY RUN] Would run: {' '.join(create_cmd)}") + return True + + print("🎨 No frontend/ found, creating Vue project...") + print(f">>> {' '.join(create_cmd)}") + print("(Follow the prompts to configure your Vue app)") + print() + result = subprocess.run( + create_cmd, + cwd=project_dir, + check=False, + ) + if result.returncode != 0: + print("❌ create-vue failed") + return False + + return True + + +def cmd_setup(args: argparse.Namespace) -> int: + """Set up or update FastAPI+Vue integration in a project. + + This unified command handles: + - Creating new projects (uv init + create-vue if needed) + - Patching existing projects with integration files + - Updating already-patched projects + """ + project_path = Path(args.project_dir) + + # Handle both "." and "/path/to/project" + if project_path.is_absolute(): + project_dir = project_path + else: + project_dir = Path.cwd() / project_path + + project_dir = project_dir.resolve() + dry_run = args.dry_run + + # Check if port options were explicitly specified (for updating existing files) + ports_specified = ( + args.vite_port != DEFAULT_VITE_PORT + or args.backend_port != DEFAULT_BACKEND_PORT + or args.prod_port != DEFAULT_PROD_PORT + ) + + # Create project directory if it doesn't exist + if not project_dir.exists(): + if dry_run: + print(f"[DRY RUN] Would create directory: {project_dir}") + else: + project_dir.mkdir(parents=True) + print(f"✅ Created {project_dir}") + + print(f"🔧 Setting up project: {project_dir}") + + if dry_run: + print("\n🏃 DRY RUN MODE - no changes will be made\n") + + # Step 1: Ensure Python project exists + if not ensure_python_project(project_dir, dry_run): + return 1 + + # Step 2: Ensure frontend exists + if not ensure_frontend(project_dir, dry_run): + return 1 + + # Detect module name + module_name = args.module_name or find_module_name(project_dir) + if not module_name: + # Derive from directory name + module_name = project_dir.name.replace("-", "_") + print(f"📦 Using module name from directory: {module_name}") + + # Ports + vite_port = args.vite_port + backend_port = args.backend_port + prod_port = args.prod_port + + # Title for templates + project_title = module_name.replace("_", " ").title() + + print(f"📦 Module: {module_name}") + print(f"🔌 Ports: Vite={vite_port}, Backend(dev)={backend_port}, Prod={prod_port}") + + # Template variables + tpl_vars = { + "MODULE_NAME": module_name, + "PROJECT_TITLE": project_title, + "VITE_PORT": vite_port, + "BACKEND_PORT": backend_port, + "PROD_PORT": prod_port, + } + + module_dir = project_dir / module_name + scripts_dir = project_dir / "scripts" + fastapi_vue_scripts = scripts_dir / "fastapi-vue" + + # Find existing FastAPI app + app_info = find_fastapi_app(module_dir) if module_dir.exists() else None + + if app_info: + app_file, app_var = app_info + print(f"📍 Found FastAPI app: {app_var} in {app_file.name}") + tpl_vars["APP_VAR"] = app_var + tpl_vars["APP_MODULE"] = app_file.stem + else: + print("📍 No existing FastAPI app found, will create new one") + app_file = None + app_var = "app" + tpl_vars["APP_VAR"] = app_var + tpl_vars["APP_MODULE"] = "app" + + # Create directories + if not dry_run: + fastapi_vue_scripts.mkdir(parents=True, exist_ok=True) + if not module_dir.exists(): + module_dir.mkdir(parents=True) + + # === Install scripts (always update our own scripts) === + 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: + template = load_template(template_path) + content = render_template(template, **tpl_vars) + write_file(dest_path, content, overwrite=True, dry_run=dry_run) + + # === Handle app module === + if app_file: + # Existing app: patch with import, route, and try to patch lifespan + patch_app_file(app_file, module_name, app_var, dry_run=dry_run) + else: + # No app: create full app.py + # Create __init__.py if missing + init_file = module_dir / "__init__.py" + if not init_file.exists(): + template = load_template("backend/__init__.py") + content = render_template(template, **tpl_vars) + write_file(init_file, content, overwrite=False, dry_run=dry_run) + + # Create app.py + app_file_path = module_dir / "app.py" + template = load_template("backend/app.py") + content = render_template(template, **tpl_vars) + write_file(app_file_path, content, overwrite=False, dry_run=dry_run) + + # === Handle __main__.py === + main_file = module_dir / "__main__.py" + if not main_file.exists() or ports_specified: + template = load_template("backend/__main__.py") + content = render_template(template, **tpl_vars) + write_file(main_file, content, overwrite=True, dry_run=dry_run) + else: + print(f"⚠️ Skipping {main_file} (exists, use --prod-port to update)") + + # === Update vite.config.js/ts === + frontend_dir = project_dir / "frontend" + if frontend_dir.exists(): + # Install the vite plugin file (always update) + plugin_file = frontend_dir / "vite-plugin-fastapi.js" + template = load_template("frontend/vite-plugin-fastapi.js") + content = render_template(template, **tpl_vars) + write_file(plugin_file, content, overwrite=True, dry_run=dry_run) + + # Find existing vite config (prefer .ts, fall back to .js) + vite_config_ts = frontend_dir / "vite.config.ts" + vite_config_js = frontend_dir / "vite.config.js" + + if vite_config_ts.exists(): + patch_vite_config( + vite_config_ts, module_name, backend_port, vite_port, dry_run + ) + elif vite_config_js.exists(): + patch_vite_config( + vite_config_js, module_name, backend_port, vite_port, dry_run + ) + else: + print("⚠️ No vite.config.ts or vite.config.js found in frontend/") + print(" Run create-vue first to generate a Vite config to patch.") + + # === Update pyproject.toml === + pyproject_path = project_dir / "pyproject.toml" + if pyproject_path.exists(): + with open(pyproject_path, "rb") as f: + data = tomllib.load(f) + + updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name) + + # Add script entry pointing to the app we found/created + if "scripts" not in updated["project"]: + updated["project"]["scripts"] = {} + script_name = module_name.replace("_", "-") + updated["project"]["scripts"][script_name] = f"{module_name}.__main__:main" + + if dry_run: + print(f"[DRY RUN] Would update {pyproject_path}") + else: + with open(pyproject_path, "wb") as f: + tomli_w.dump(updated, f) + print(f"✅ Updated {pyproject_path}") + + # === Update .gitignore === + gitignore_path = project_dir / ".gitignore" + gitignore_entry = f"{module_name}/frontend-build/" + if gitignore_path.exists(): + gitignore_content = gitignore_path.read_text() + if gitignore_entry not in gitignore_content: + if dry_run: + print(f"[DRY RUN] Would add {gitignore_entry} to .gitignore") + else: + with open(gitignore_path, "a") as f: + if not gitignore_content.endswith("\n"): + f.write("\n") + f.write(f"{gitignore_entry}\n") + print(f"✅ Added {gitignore_entry} to .gitignore") + elif dry_run: + print(f"[DRY RUN] Would create .gitignore with {gitignore_entry}") + else: + gitignore_path.write_text(f"{gitignore_entry}\n") + print("✅ Created .gitignore") + + print() + print("=" * 60) + print("✅ Setup complete!") + print("=" * 60) + print(f""" +Next steps: + +1. Install dependencies: + cd {project_dir} + uv sync + +2. Start development server: + uv run scripts/devserver.py + +3. Build for production: + uv build + +4. Run production server: + uv run {module_name.replace("_", "-")} +""") + + return 0 + + +# ============================================================================= +# Main entry point +# ============================================================================= + + +def is_uninitialized_folder(path: Path) -> bool: + """Check if a folder appears to be completely uninitialized.""" + return ( + not (path / "pyproject.toml").exists() and not (path / "package.json").exists() + ) + + +def is_already_patched(path: Path) -> bool: + """Check if a folder has already been patched by fastapi-vue-setup.""" + # Check for our scripts directory + if (path / "scripts" / "fastapi-vue").exists(): + return True + + # Check for vite plugin in frontend + if (path / "frontend" / "vite-plugin-fastapi.js").exists(): + return True + + return False + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Set up FastAPI+Vue projects with integrated build/dev systems", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +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 . --vite-port 3000 Update port configuration +""", + ) + parser.add_argument( + "project_dir", + nargs="?", + default=None, + help="Project directory (default: current directory)", + ) + parser.add_argument("--module-name", help="Python module name (auto-detected)") + parser.add_argument( + "--vite-port", + type=int, + default=DEFAULT_VITE_PORT, + help=f"Vite dev server port (default: {DEFAULT_VITE_PORT})", + ) + parser.add_argument( + "--backend-port", + type=int, + default=DEFAULT_BACKEND_PORT, + help=f"Backend API port in dev mode (default: {DEFAULT_BACKEND_PORT})", + ) + parser.add_argument( + "--prod-port", + type=int, + default=DEFAULT_PROD_PORT, + help=f"Production server port (default: {DEFAULT_PROD_PORT})", + ) + parser.add_argument( + "--dry-run", action="store_true", help="Show what would be done" + ) + + args = parser.parse_args() + + # Handle default project directory with safety check + if args.project_dir is None: + cwd = Path.cwd() + if not is_uninitialized_folder(cwd) and not is_already_patched(cwd): + print( + "⚠️ Current directory contains an existing project that hasn't been patched yet." + ) + print() + print( + " If you want to set up FastAPI+Vue integration in the current directory, run:" + ) + print(" fastapi-vue-setup .") + print() + print(" Or specify a new project directory:") + print(" fastapi-vue-setup my-new-project") + print() + print(" Python project will be at root, while Vue lives in frontend/.") + print() + return 1 + args.project_dir = "." + + return cmd_setup(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c3e3d06 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "fastapi-vue-setup" +dynamic = ["version"] +description = "Tool to create or patch FastAPI+Vue projects with integrated build/dev systems" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "tomli-w>=1.0.0", +] + +[project.urls] +Homepage = "https://git.zi.fi/LeoVasanko/fastapi-vue-setup" +Repository = "https://github.com/LeoVasanko/fastapi-vue-setup" + +[project.scripts] +fastapi-vue-setup = "fastapi_vue_setup:main" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.targets.wheel] +include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"] + +[dependency-groups] +dev = ["ruff"] diff --git a/template/backend/__init__.py b/template/backend/__init__.py new file mode 100644 index 0000000..50cf5e0 --- /dev/null +++ b/template/backend/__init__.py @@ -0,0 +1,3 @@ +"""{{PROJECT_TITLE}} package.""" + +__version__ = "0.1.0" diff --git a/template/backend/__main__.py b/template/backend/__main__.py new file mode 100644 index 0000000..af70d01 --- /dev/null +++ b/template/backend/__main__.py @@ -0,0 +1,32 @@ +"""Entry point for the application.""" + +import sys + + +def main(): + """Run the FastAPI application using uvicorn.""" + import uvicorn + + if len(sys.argv) > 1: + endpoint = sys.argv[1] + if ":" in endpoint: + host, port = endpoint.rsplit(":", 1) + host = host or "localhost" + port = int(port) + else: + host = "localhost" + port = int(endpoint) + else: + host = "localhost" + port = {{PROD_PORT}} + + uvicorn.run( + "{{MODULE_NAME}}.app:app", + host=host, + port=port, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/template/backend/app.py b/template/backend/app.py new file mode 100644 index 0000000..d9bdce7 --- /dev/null +++ b/template/backend/app.py @@ -0,0 +1,40 @@ +"""Main FastAPI application.""" + +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi_vue import Frontend + +# Frontend static file server - configure options here +frontend = Frontend( + Path(__file__).parent / "frontend-build", + spa=True, + favicon="/assets/favicon", + cached=["/assets/"], +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Manage app startup and shutdown resources.""" + await frontend.load() + yield + + +app = FastAPI(title="{{PROJECT_TITLE}}", lifespan=lifespan) + + +@app.get("/api/health") +async def health_check(): + """Health check endpoint for the dev server.""" + return {"status": "ok"} + + +# Add your API routes here +# @app.get("/api/example") +# async def example(): +# return {"message": "Hello World"} + + +frontend.route(app, "/") diff --git a/template/frontend/vite-plugin-fastapi.js b/template/frontend/vite-plugin-fastapi.js new file mode 100644 index 0000000..d2a82ca --- /dev/null +++ b/template/frontend/vite-plugin-fastapi.js @@ -0,0 +1,37 @@ +/** + * FastAPI-Vue Vite 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): + * VITE_PORT=5173 - Vite dev server port + * VITE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying + */ + +const backendUrl = + process.env.VITE_BACKEND_URL || "http://localhost:{{BACKEND_PORT}}"; +const vitePort = parseInt(process.env.VITE_PORT || "{{VITE_PORT}}"); + +export default { + name: "fastapi-vite", + config: () => ({ + server: { + host: "localhost", + port: vitePort, + strictPort: true, + proxy: { + "/api": { + target: backendUrl, + changeOrigin: false, + ws: true, + }, + }, + }, + build: { + outDir: "../{{MODULE_NAME}}/frontend-build", + emptyOutDir: true, + }, + }), +}; diff --git a/template/scripts/devserver.py b/template/scripts/devserver.py new file mode 100644 index 0000000..03124d2 --- /dev/null +++ b/template/scripts/devserver.py @@ -0,0 +1,268 @@ +#!/usr/bin/env -S uv run +"""Run Vite development server for frontend and FastAPI backend with auto-reload. + +Usage: + uv run scripts/devserver.py [host:port] + +The optional host:port argument sets where the Vite frontend listens. +Supported forms: host[:port], :port (all interfaces), or just port. +Backend always listens on localhost:{{BACKEND_PORT}}. + +Environment: + JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun). + FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is. +""" + +import asyncio +import contextlib +import ipaddress +import os +import sys +from pathlib import Path +from sys import stderr +from urllib.parse import urlparse + +import httpx + +exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102 + +DEFAULT_HOST = "localhost" +DEFAULT_VITE_PORT = {{VITE_PORT}} +BACKEND_PORT = {{BACKEND_PORT}} +FRONTEND_PATH = Path(__file__).parent.parent / "frontend" + +BUN_BUG = """\ +┃ ⚠️ Bun cannot correctly proxy API requests to the backend. +┃ Bug report: https://github.com/oven-sh/bun/issues/9882 +┃ +┃ Consider using deno or npm instead for development. +""" + + +def parse_endpoint(value: str | None) -> tuple[str | None, int, bool]: + """Parse an endpoint for Vite (no unix socket support). + + Returns (host, port, all_ifaces). + """ + if not value: + return DEFAULT_HOST, DEFAULT_VITE_PORT, False + + # Port only (numeric) -> localhost:port + if value.isdigit(): + return DEFAULT_HOST, int(value), False + + # Leading colon :port -> bind all interfaces + if value.startswith(":") and value != ":": + port_part = value[1:] + if not port_part.isdigit(): + raise SystemExit(f"Invalid port in '{value}'") + return None, int(port_part), True + + # Unbracketed IPv6 (cannot safely contain a port) + if value.count(":") > 1 and not value.startswith("["): + try: + ipaddress.IPv6Address(value) + except ValueError as e: + raise SystemExit(f"Invalid IPv6 address '{value}': {e}") from e + return value, DEFAULT_VITE_PORT, False + + # Use urllib.parse for everything else + parsed = urlparse(f"//{value}") + host = parsed.hostname or DEFAULT_HOST + port = parsed.port or DEFAULT_VITE_PORT + + return host, port, False + + +def resolve_frontend_tools( + vite_host: str | None, vite_port: int, all_ifaces: bool +) -> tuple[list[str], list[str], str]: + """Resolve frontend install and dev commands. + + Returns (install_cmd, dev_cmd, tool_name). + Raises SystemExit if tools are not available. + """ + if not (FRONTEND_PATH / "package.json").exists(): + stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n") + raise SystemExit(1) + + result = find_js_runtime() + if result is None: + if not os.environ.get("JS_RUNTIME"): + stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n") + raise SystemExit(1) + + tool, name = result + + install_args = { + "deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"), + "npm": ("install", "--silent"), + "bun": ("install", "--silent"), + } + dev_args = { + "deno": ("run", "dev", "--"), + "npm": ("--silent", "run", "dev", "--"), + "bun": ("run", "dev", "--"), + } + + install_cmd = [tool, *install_args[name]] + dev_cmd = [tool, *dev_args[name], "--port", str(vite_port)] + + if all_ifaces: + dev_cmd.append("--host") + elif vite_host: + dev_cmd.extend(["--host", vite_host]) + + if name == "bun": + stderr.write(BUN_BUG) + + return install_cmd, dev_cmd, name + + +async def wait_for_backend(): + """Wait for the backend to be ready by polling the health endpoint.""" + max_attempts = 50 + async with httpx.AsyncClient() as client: + for attempt in range(max_attempts): + try: + await client.get(f"http://localhost:{BACKEND_PORT}", timeout=1.0) + stderr.write("✓ Backend ready!\n") + return True + except httpx.RequestError: + if attempt == max_attempts - 1: + stderr.write("┃ ⚠️ Backend didn't start in time\n") + return False + await asyncio.sleep(0.1) + return False + + +async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> None: + """Gracefully terminate a subprocess.""" + if proc.returncode is not None: + return + try: + proc.terminate() + except ProcessLookupError: + return + try: + await asyncio.wait_for(proc.wait(), timeout=2) + except TimeoutError: + try: + proc.kill() + except ProcessLookupError: + return + await proc.wait() + + +async def run_devserver( + vite_host: str | None, vite_port: int, all_ifaces: bool +) -> None: + """Run the development server with install, backend, and frontend.""" + install_cmd, dev_cmd, tool_name = resolve_frontend_tools( + vite_host, vite_port, all_ifaces + ) + + # Tell the backend where the Vite dev server is + os.environ["FASTAPI_VUE_FRONTEND_URL"] = ( + f"http://{vite_host or 'localhost'}:{vite_port}" + ) + # Tell Vite where the backend is (for proxying /api requests) + os.environ["VITE_BACKEND_URL"] = f"http://localhost:{BACKEND_PORT}" + cwd = str(Path(__file__).parent.parent) + frontend_cwd = str(FRONTEND_PATH) + + backend_proc: asyncio.subprocess.Process | None = None + install_proc: asyncio.subprocess.Process | None = None + frontend_proc: asyncio.subprocess.Process | None = None + + try: + # Start install (concurrent with backend) + stderr.write(f">>> {tool_name} {' '.join(install_cmd[1:])}\n") + install_proc = await asyncio.create_subprocess_exec( + *install_cmd, cwd=frontend_cwd + ) + + await asyncio.sleep(0.1) + + # Start backend (concurrent with install) + backend_cmd = [ + "uvicorn", + "{{MODULE_NAME}}.app:app", + "--host", + "localhost", + "--port", + str(BACKEND_PORT), + "--reload", + ] + stderr.write(f">>> {' '.join(backend_cmd)}\n") + backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd) + + # Wait for install to complete and backend to be ready + install_task = asyncio.create_task(install_proc.wait(), name="install") + backend_ready_task = asyncio.create_task( + wait_for_backend(), name="backend_ready" + ) + + done, pending = await asyncio.wait( + {install_task, backend_ready_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + for task in done: + if task.get_name() == "install": + if task.result() != 0: + stderr.write("┃ ⚠️ Install failed\n") + raise SystemExit(1) + elif task.get_name() == "backend_ready" and not task.result(): + raise SystemExit(1) + + if pending: + done2, _ = await asyncio.wait(pending) + for task in done2: + if task.get_name() == "install": + if task.result() != 0: + stderr.write("┃ ⚠️ Install failed\n") + raise SystemExit(1) + elif task.get_name() == "backend_ready" and not task.result(): + raise SystemExit(1) + + install_proc = None + + # Start Vite dev server + stderr.write(f">>> {tool_name} {' '.join(dev_cmd[1:])}\n") + frontend_proc = await asyncio.create_subprocess_exec(*dev_cmd, cwd=frontend_cwd) + + # Wait for either process to exit + done, pending = await asyncio.wait( + { + asyncio.create_task(backend_proc.wait(), name="backend"), + asyncio.create_task(frontend_proc.wait(), name="frontend"), + }, + return_when=asyncio.FIRST_COMPLETED, + ) + for t in done: + t.result() + for t in pending: + t.cancel() + + except asyncio.CancelledError: + stderr.write("\n✓ Shutting down...\n") + finally: + if frontend_proc is not None: + await _terminate_process(frontend_proc, "frontend") + if install_proc is not None: + await _terminate_process(install_proc, "install") + if backend_proc is not None: + await _terminate_process(backend_proc, "backend") + + +def main(): + hostport = sys.argv[1] if len(sys.argv) > 1 else None + vite_host, vite_port, all_ifaces = parse_endpoint(hostport) + + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(run_devserver(vite_host, vite_port, all_ifaces)) + + +if __name__ == "__main__": + main() diff --git a/template/scripts/fastapi-vue/build-frontend.py b/template/scripts/fastapi-vue/build-frontend.py new file mode 100644 index 0000000..44ad4d3 --- /dev/null +++ b/template/scripts/fastapi-vue/build-frontend.py @@ -0,0 +1,34 @@ +"""Hatch build hook for building Vue frontend during package build.""" + +import subprocess +from pathlib import Path +from sys import stderr + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102 + + +def run(cmd, **kwargs): + """Run a command and display it.""" + display_cmd = [Path(cmd[0]).name, *cmd[1:]] + stderr.write(f"### {' '.join(display_cmd)}\n") + subprocess.run(cmd, check=True, **kwargs) + + +class CustomBuildHook(BuildHookInterface): + """Build hook that compiles Vue frontend before packaging.""" + + def initialize(self, version, build_data): + super().initialize(version, build_data) + stderr.write(">>> Building the frontend\n") + + install_cmd, build_cmd = find_build_tool() + + try: + run(install_cmd, cwd="frontend") + stderr.write("\n") + run(build_cmd, cwd="frontend") + except Exception as e: + stderr.write(f"Error occurred while building frontend: {e}\n") + raise diff --git a/template/scripts/fastapi-vue/util.py b/template/scripts/fastapi-vue/util.py new file mode 100644 index 0000000..76324b4 --- /dev/null +++ b/template/scripts/fastapi-vue/util.py @@ -0,0 +1,86 @@ +"""Shared utilities for build and dev scripts.""" + +import os +import shutil +from pathlib import Path +from sys import stderr + + +def find_js_runtime() -> tuple[str, str] | None: + """Find a JavaScript runtime from JS_RUNTIME env or auto-detect. + + Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun". + Returns None if no runtime is found. + """ + options = ["deno", "npm", "bun"] + + # Check for JS_RUNTIME environment variable + if js_runtime_env := os.environ.get("JS_RUNTIME"): + js_runtime = js_runtime_env + js_path = Path(js_runtime) + runtime_name = js_path.name + # Map node to npm + if runtime_name == "node": + runtime_name = "npm" + js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm" + for option in options: + if option == runtime_name or runtime_name.startswith(option): + tool = shutil.which(js_runtime) + if tool is None: + stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not found\n") + return None + return tool, option + stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not recognized\n") + return None + + # Auto-detect + for option in options: + if tool := shutil.which(option): + return tool, option + return None + + +def find_build_tool(): + """Find JavaScript runtime and construct install/build commands. + + Returns (install_cmd, build_cmd) tuples of command lists. + Raises RuntimeError if no runtime is found. + """ + install = { + "deno": ("install", "--allow-scripts=npm:vue-demi"), + "npm": ("install",), + "bun": ("--bun", "install"), + } + build = { + "deno": ("task", "build"), + "npm": ("run", "build"), + "bun": ("--bun", "run", "build"), + } + + result = find_js_runtime() + if result is None: + raise RuntimeError( + "Deno, npm or Bun is required for building but none was found" + ) + + tool, name = result + return [tool, *install[name]], [tool, *build[name]] + + +def find_dev_tool(): + """Find JavaScript runtime and construct dev command. + + Returns (dev_cmd, tool_name) or (None, None) if not found. + """ + dev_args = { + "deno": ("run", "dev", "--"), + "npm": ("--silent", "run", "dev", "--"), + "bun": ("run", "dev", "--"), + } + + result = find_js_runtime() + if result is None: + return None, None + + tool, name = result + return [tool, *dev_args[name]], name