Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.*
|
||||||
|
!.gitignore
|
||||||
|
*.lock
|
||||||
|
__pycache__/
|
||||||
|
dist/
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""{{PROJECT_TITLE}} package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -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()
|
||||||
@@ -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, "/")
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user