Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44e06df66c |
@@ -3,4 +3,3 @@
|
|||||||
*.lock
|
*.lock
|
||||||
__pycache__/
|
__pycache__/
|
||||||
dist/
|
dist/
|
||||||
/_version.py
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
# fastapi-vue-setup
|
# fastapi-vue-setup
|
||||||
|
|
||||||
Tool to create or patch FastAPI project with a Vue frontend, with integrated build and development systems. The Python package will not need any JS runtime because it includes a prebuilt Vue frontend in it. For development (Vite and FastAPI auto reloads) and building the package one of npm, deno or bun is required (node is recommended due to bugs in deno and bun).
|
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
|
## Features
|
||||||
|
|
||||||
- **No JavaScript**: Your Python package can be installed and used without any JS runtime
|
- **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`
|
- **Integrated build system**: Vue frontend builds into Python package during `uv build`
|
||||||
- **Development server**: Single command runs Vite + FastAPI with hot-reload
|
- **Development server**: Single command runs Vite + FastAPI with hot-reload
|
||||||
- **Optimized static serving**: Caching, zstd compression and SPA support
|
- **Optimized static serving**: zstd compression, ETag caching, SPA support
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -26,98 +28,77 @@ uvx fastapi-vue-setup my-app
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
The script may be used to create an all new project folder or to patch or update an existing project to use this framework. It autodetects the project folder given and performs the appropriate actions.
|
|
||||||
|
|
||||||
### Create a new project
|
### Create a new project
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
fastapi-vue-setup my-app
|
fastapi-vue-setup new my-app
|
||||||
```
|
```
|
||||||
|
|
||||||
This will:
|
This will:
|
||||||
|
|
||||||
1. Run `uv init my-app`
|
1. Run `uv init my-app`
|
||||||
2. Run `create-vue frontend` (interactive - choose your Vue options)
|
2. Run `npm create vue@latest frontend` (interactive)
|
||||||
3. Patch the project with FastAPI integration
|
3. Patch the project with FastAPI integration
|
||||||
4. Install dependencies via `uv add`
|
|
||||||
|
|
||||||
### Patch an existing project
|
### Patch an existing project
|
||||||
|
|
||||||
You should have your pyproject.toml at the current working directory, and Vue with its package.json under `frontend/`. If either one is missing, new applications will be initialised. Otherwise we only patch what can be patched without breaking your existing projects.
|
```bash
|
||||||
|
fastapi-vue-setup patch /path/to/project
|
||||||
```sh
|
|
||||||
fastapi-vue-setup .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### CLI Options
|
|
||||||
|
|
||||||
```
|
|
||||||
fastapi-vue-setup [project-dir] [options]
|
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--module-name NAME Python module name (auto-detected from pyproject.toml)
|
|
||||||
--dry-run Preview changes without modifying files
|
- `--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
|
## Port Configuration
|
||||||
|
|
||||||
In development, you access the Vite dev server at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5180. Ports and hosts of Vite and FastAPI are configurable by `devserver.py` arguments.
|
The tool uses three distinct ports:
|
||||||
|
|
||||||
In production, FastAPI serves both the API and static files at `http://localhost:5080`. Configurable by `host:port` argument with defaults set in `__main__.py`
|
| 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` |
|
||||||
|
|
||||||
## Main CLI
|
In development, you access the app at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5174.
|
||||||
|
|
||||||
If your project didn't already have `__main__.py`, we create one that runs the FastAPI app with richer configuration than what the FastAPI CLI offers. Running your module starts it in production mode, and optionally host:port may be given as argument to specify where it listens.
|
In production, FastAPI serves both the API and static files from the same port (8000).
|
||||||
|
|
||||||
If you are running behind a reverse proxy like [Caddy](https://caddyserver.com/) on localhost, your app will trust the proxy headers it sends. However, if you need to configure another proxy host or IP, set `FORWARDED_ALLOW_IPS` env variable before running the server.
|
|
||||||
|
|
||||||
The devserver script depends on this CLI entry for running the backend. You will have to modify the `devserver.py` script if your app has its own incompatible main module. Note that we set FastAPI debug mode and Uvicorn reload when configured via `FASTAPI_VUE_BACKEND_URL` env variable (set by `devserver.py`), while for normal production use these stay disabled. The same variable also controls static files serving (disabled in dev mode).
|
|
||||||
|
|
||||||
|
|
||||||
## Vite Plugin Configuration
|
|
||||||
|
|
||||||
The `fastapiVue()` plugin in `vite.config.ts` accepts options to customize proxy behavior:
|
|
||||||
|
|
||||||
```js
|
|
||||||
import fastapiVue from "./vite-plugin-fastapi.js";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
vue(),
|
|
||||||
// Default: proxies only /api
|
|
||||||
fastapiVue(),
|
|
||||||
|
|
||||||
// Or specify custom paths to proxy to backend
|
|
||||||
fastapiVue({ paths: ["/api", "/auth", "/ws"] }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
The plugin reads environment `FASTAPI_VUE_BACKEND_URL` (default: `http://localhost:5180`) to determine where to proxy requests. This is set automatically by `devserver.py`.
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
After patching, your project will have:
|
||||||
|
|
||||||
```
|
```
|
||||||
my-app/
|
my-app/
|
||||||
├── frontend/ # Vue application
|
├── frontend/ # Vue.js application
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ ├── vite-plugin-fastapi.js
|
│ ├── vite.config.ts # Builds to ../my_app/frontend-build
|
||||||
│ ├── vite.config.js
|
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── my_app/ # Python module (files included in sdist)
|
├── my_app/ # Python package
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── __main__.py # CLI entrypoint
|
│ ├── __main__.py # CLI entry point
|
||||||
│ ├── app.py # FastAPI application
|
│ ├── app.py # FastAPI application
|
||||||
│ └── frontend-build/ # Built frontend (gitignored)
|
│ └── frontend-build/ # Built frontend (gitignored)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── devserver.py # CLI dev server (only in source tree)
|
│ ├── devserver.py # Development server
|
||||||
│ └── fastapi-vue/
|
│ └── fastapi-vue/ # Build utilities
|
||||||
│ ├── build-frontend.py
|
└── pyproject.toml # Project configuration
|
||||||
│ └── util.py
|
|
||||||
└── pyproject.toml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The project directory tree looks roughly like this after project creation or patching. The script finds your existing app module and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection. New Python and Vue projects are created automatically if none exist.
|
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
|
## Development Workflow
|
||||||
|
|
||||||
@@ -132,6 +113,12 @@ uv build
|
|||||||
uv run my-app
|
uv run my-app
|
||||||
```
|
```
|
||||||
|
|
||||||
## Frontend serving
|
## Static File Serving
|
||||||
|
|
||||||
Your FastAPI app will use [fastapi-vue](https://git.zi.fi/LeoVasanko/fastapi-vue) to serve the frontend files. Refer to that package's documentation for further configuration.
|
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
|
||||||
|
|||||||
+134
-237
@@ -7,7 +7,12 @@ Usage:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--module-name NAME Python module name (auto-detected from pyproject.toml)
|
--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
|
--dry-run Show what would be done without making changes
|
||||||
|
|
||||||
|
Port options update existing files when specified, allowing reconfiguration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -17,25 +22,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from textwrap import indent
|
|
||||||
|
|
||||||
import tomli_w
|
import tomli_w
|
||||||
|
|
||||||
try:
|
# Port configuration (keeping them distinct)
|
||||||
from _version import __version__
|
DEFAULT_VITE_PORT = 5173 # Vite dev server (frontend HMR)
|
||||||
except ImportError:
|
DEFAULT_BACKEND_PORT = 5180 # FastAPI in dev mode (Vite proxies /api here)
|
||||||
__version__ = "0.0.0+unknown"
|
DEFAULT_PROD_PORT = 5080 # Production server (static files served by FastAPI)
|
||||||
|
|
||||||
# Template directory
|
# Template directory
|
||||||
TEMPLATE_DIR = Path(__file__).parent / "template"
|
TEMPLATE_DIR = Path(__file__).parent / "template"
|
||||||
|
|
||||||
# pyproject.toml additions for patched projects
|
# pyproject.toml additions for patched projects
|
||||||
PYPROJECT_ADDITIONS = {
|
PYPROJECT_ADDITIONS = {
|
||||||
|
"project": {
|
||||||
|
"dependencies": [
|
||||||
|
"fastapi-vue>=0.1.0",
|
||||||
|
],
|
||||||
|
},
|
||||||
"tool": {
|
"tool": {
|
||||||
"hatch": {
|
"hatch": {
|
||||||
"build": {
|
"build": {
|
||||||
"packages": ["MODULE_NAME"],
|
"artifacts": ["{{MODULE_NAME}}/frontend-build"],
|
||||||
"artifacts": ["MODULE_NAME/frontend-build"],
|
|
||||||
"targets": {
|
"targets": {
|
||||||
"sdist": {
|
"sdist": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
@@ -50,71 +58,6 @@ PYPROJECT_ADDITIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Frontend instantiation block for patching existing apps
|
|
||||||
FRONTEND_BLOCK = """
|
|
||||||
# Vue Frontend static files
|
|
||||||
frontend = Frontend(
|
|
||||||
Path(__file__).with_name("frontend-build"), spa=True, cached=["/assets/"]
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
# TypeScript health check script for Vue components
|
|
||||||
TS_HEALTH_CHECK_SCRIPT = """\
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
|
|
||||||
const backendStatus = ref<'checking' | 'connected' | 'error'>('checking')
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/health')
|
|
||||||
backendStatus.value = res.ok ? 'connected' : 'error'
|
|
||||||
} catch {
|
|
||||||
backendStatus.value = 'error'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
"""
|
|
||||||
|
|
||||||
# JavaScript health check script for Vue components
|
|
||||||
JS_HEALTH_CHECK_SCRIPT = """\
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
|
|
||||||
const backendStatus = ref('checking')
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/health')
|
|
||||||
backendStatus.value = res.ok ? 'connected' : 'error'
|
|
||||||
} catch {
|
|
||||||
backendStatus.value = 'error'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Status indicator template for Vue components
|
|
||||||
STATUS_SPAN_TEMPLATE = """\
|
|
||||||
<span style="white-space: nowrap">
|
|
||||||
— FastAPI:
|
|
||||||
<span v-if="backendStatus === 'checking'">⏳</span>
|
|
||||||
<span v-else-if="backendStatus === 'connected'">✅</span>
|
|
||||||
<span v-else>❌ not reachable</span>
|
|
||||||
</span>
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Setup complete message template
|
|
||||||
SETUP_COMPLETE_MESSAGE = """
|
|
||||||
Next steps:
|
|
||||||
|
|
||||||
1. Build for production:
|
|
||||||
CD_CMDuv build
|
|
||||||
|
|
||||||
2. Start development server:
|
|
||||||
CD_CMDuv run scripts/devserver.py
|
|
||||||
|
|
||||||
3. Run production server:
|
|
||||||
CD_CMDuv run SCRIPT_NAME
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Utility functions
|
# Utility functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -184,10 +127,10 @@ def _find_app_in_file(path: Path) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def render_template(template: str, **kwargs) -> str:
|
def render_template(template: str, **kwargs) -> str:
|
||||||
"""Simple template rendering replacing KEY with value."""
|
"""Simple template rendering with {{KEY}} placeholders."""
|
||||||
result = template
|
result = template
|
||||||
for key, value in kwargs.items():
|
for key, value in kwargs.items():
|
||||||
result = result.replace(key, value)
|
result = result.replace(f"{{{{{key}}}}}", str(value))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -220,6 +163,16 @@ def patch_app_file(
|
|||||||
lines = content.split("\n")
|
lines = content.split("\n")
|
||||||
import_line = "from fastapi_vue import Frontend"
|
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}, "/")'
|
route_line = f'frontend.route({app_var}, "/")'
|
||||||
|
|
||||||
# Find last import line and check if pathlib is imported
|
# Find last import line and check if pathlib is imported
|
||||||
@@ -240,7 +193,7 @@ def patch_app_file(
|
|||||||
lines.insert(last_import_idx + 1, "from pathlib import Path")
|
lines.insert(last_import_idx + 1, "from pathlib import Path")
|
||||||
last_import_idx += 1
|
last_import_idx += 1
|
||||||
lines.insert(last_import_idx + 1, import_line)
|
lines.insert(last_import_idx + 1, import_line)
|
||||||
lines.insert(last_import_idx + 2, FRONTEND_BLOCK)
|
lines.insert(last_import_idx + 2, frontend_block)
|
||||||
|
|
||||||
# Append route at end
|
# Append route at end
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -278,7 +231,7 @@ def patch_app_file(
|
|||||||
|
|
||||||
if not lifespan_patched:
|
if not lifespan_patched:
|
||||||
# Check if they're using deprecated on_event
|
# Check if they're using deprecated on_event
|
||||||
if f"@{app_var}.on_event" in content:
|
if "@" + app_var + ".on_event" in content or f"@{app_var}.on_event" in content:
|
||||||
print()
|
print()
|
||||||
print("⚠️ Your app uses the deprecated @app.on_event decorator.")
|
print("⚠️ Your app uses the deprecated @app.on_event decorator.")
|
||||||
print(" Please migrate to the lifespan pattern and add:")
|
print(" Please migrate to the lifespan pattern and add:")
|
||||||
@@ -297,6 +250,8 @@ def patch_app_file(
|
|||||||
def patch_vite_config(
|
def patch_vite_config(
|
||||||
path: Path,
|
path: Path,
|
||||||
module_name: str,
|
module_name: str,
|
||||||
|
backend_port: int,
|
||||||
|
vite_port: int,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Patch an existing vite.config.js/ts by adding fastapi-vue plugin.
|
"""Patch an existing vite.config.js/ts by adding fastapi-vue plugin.
|
||||||
@@ -360,92 +315,8 @@ def patch_vite_config(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bool:
|
|
||||||
"""Patch Vue app to include FastAPI backend health check.
|
|
||||||
|
|
||||||
Tries HelloWorld.vue first (full demo), then falls back to App.vue (minimal).
|
|
||||||
Works with both JS and TS versions created by create-vue.
|
|
||||||
"""
|
|
||||||
# Find the file to patch - prefer HelloWorld.vue, fall back to App.vue
|
|
||||||
target_file = None
|
|
||||||
|
|
||||||
# Try HelloWorld.vue first (full demo app)
|
|
||||||
hello_world = frontend_dir / "src" / "components" / "HelloWorld.vue"
|
|
||||||
if hello_world.exists():
|
|
||||||
target_file = hello_world
|
|
||||||
else:
|
|
||||||
# Try finding HelloWorld.vue elsewhere
|
|
||||||
for path in frontend_dir.glob("src/**/HelloWorld.vue"):
|
|
||||||
target_file = path
|
|
||||||
break
|
|
||||||
|
|
||||||
# Fall back to App.vue (minimal app)
|
|
||||||
if target_file is None:
|
|
||||||
app_vue = frontend_dir / "src" / "App.vue"
|
|
||||||
if app_vue.exists():
|
|
||||||
target_file = app_vue
|
|
||||||
|
|
||||||
if target_file is None:
|
|
||||||
print("⚠️ No Vue file found to patch, skipping frontend health check")
|
|
||||||
return False
|
|
||||||
|
|
||||||
content = target_file.read_text()
|
|
||||||
|
|
||||||
# Check if already patched
|
|
||||||
if "/api/health" in content:
|
|
||||||
print(f"⚠️ Skipping {target_file} (already patched)")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print(f"[DRY RUN] Would patch {target_file}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Detect if TypeScript (has lang="ts" in script tag)
|
|
||||||
is_typescript = 'lang="ts"' in content
|
|
||||||
|
|
||||||
# Build the script content based on JS/TS
|
|
||||||
script_addition = (
|
|
||||||
TS_HEALTH_CHECK_SCRIPT if is_typescript else JS_HEALTH_CHECK_SCRIPT
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert script addition before </script>
|
|
||||||
script_end_match = re.search(r"</script>", content)
|
|
||||||
if script_end_match:
|
|
||||||
insert_pos = script_end_match.start()
|
|
||||||
content = content[:insert_pos] + script_addition + content[insert_pos:]
|
|
||||||
|
|
||||||
# Insert status inline - find the best place based on file type
|
|
||||||
# For HelloWorld.vue: insert before </h3>
|
|
||||||
# For App.vue (minimal): insert before the last </p> in template
|
|
||||||
if "HelloWorld" in str(target_file):
|
|
||||||
# Insert before closing </h3>
|
|
||||||
h3_close = content.find(" </h3>")
|
|
||||||
if h3_close == -1:
|
|
||||||
return False
|
|
||||||
before, after = content[:h3_close], content[h3_close:]
|
|
||||||
content = f"{before}{indent(STATUS_SPAN_TEMPLATE, ' ')}{after}"
|
|
||||||
else:
|
|
||||||
# Minimal App.vue - insert before the last </p> before </template>
|
|
||||||
template_end = content.find("</template>")
|
|
||||||
if template_end != -1:
|
|
||||||
# Find last </p> before </template>
|
|
||||||
last_p = content.rfind(" </p>", 0, template_end)
|
|
||||||
if last_p == -1:
|
|
||||||
return False
|
|
||||||
before, after = content[:last_p], content[last_p:]
|
|
||||||
content = f"{before}{STATUS_SPAN_TEMPLATE}{after}"
|
|
||||||
|
|
||||||
target_file.write_text(content)
|
|
||||||
print(f"✅ Patched {target_file}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def write_file(
|
def write_file(
|
||||||
path: Path,
|
path: Path, content: str, overwrite: bool = True, dry_run: bool = False
|
||||||
content: str,
|
|
||||||
overwrite: bool = True,
|
|
||||||
dry_run: bool = False,
|
|
||||||
executable: bool = False,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Write content to a file, handling existing files and dry-run."""
|
"""Write content to a file, handling existing files and dry-run."""
|
||||||
exists = path.exists()
|
exists = path.exists()
|
||||||
@@ -460,8 +331,6 @@ def write_file(
|
|||||||
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(content)
|
path.write_text(content)
|
||||||
if executable and sys.platform != "win32":
|
|
||||||
path.chmod(path.stat().st_mode | 0o111)
|
|
||||||
action = "Updated" if exists else "Created"
|
action = "Updated" if exists else "Created"
|
||||||
print(f"✅ {action} {path}")
|
print(f"✅ {action} {path}")
|
||||||
return True
|
return True
|
||||||
@@ -471,18 +340,19 @@ def merge_pyproject(data: dict, additions: dict, module_name: str) -> dict:
|
|||||||
"""Merge additions into pyproject.toml data."""
|
"""Merge additions into pyproject.toml data."""
|
||||||
result = data.copy()
|
result = data.copy()
|
||||||
|
|
||||||
# Ensure hatchling build system is configured
|
|
||||||
if "build-system" not in result:
|
|
||||||
result["build-system"] = {}
|
|
||||||
result["build-system"]["requires"] = ["hatchling"]
|
|
||||||
result["build-system"]["build-backend"] = "hatchling.build"
|
|
||||||
|
|
||||||
# Add dependencies
|
# Add dependencies
|
||||||
if "project" not in result:
|
if "project" not in result:
|
||||||
result["project"] = {}
|
result["project"] = {}
|
||||||
if "dependencies" not in result["project"]:
|
if "dependencies" not in result["project"]:
|
||||||
result["project"]["dependencies"] = []
|
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
|
# Add hatch build config
|
||||||
if "tool" not in result:
|
if "tool" not in result:
|
||||||
result["tool"] = {}
|
result["tool"] = {}
|
||||||
@@ -492,11 +362,8 @@ def merge_pyproject(data: dict, additions: dict, module_name: str) -> dict:
|
|||||||
result["tool"]["hatch"]["build"] = {}
|
result["tool"]["hatch"]["build"] = {}
|
||||||
|
|
||||||
hatch_build = additions["tool"]["hatch"]["build"]
|
hatch_build = additions["tool"]["hatch"]["build"]
|
||||||
result["tool"]["hatch"]["build"]["packages"] = [
|
|
||||||
p.replace("MODULE_NAME", module_name) for p in hatch_build["packages"]
|
|
||||||
]
|
|
||||||
result["tool"]["hatch"]["build"]["artifacts"] = [
|
result["tool"]["hatch"]["build"]["artifacts"] = [
|
||||||
a.replace("MODULE_NAME", module_name) for a in 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"]
|
result["tool"]["hatch"]["build"]["only-packages"] = hatch_build["only-packages"]
|
||||||
|
|
||||||
@@ -568,22 +435,23 @@ def ensure_python_project(project_dir: Path, dry_run: bool = False) -> bool:
|
|||||||
print("❌ uv init failed")
|
print("❌ uv init failed")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Remove files created by uv init that we don't need
|
# Remove hello.py if created
|
||||||
for filename in ["hello.py", "main.py", ".python-version"]:
|
hello_py = project_dir / "hello.py"
|
||||||
filepath = project_dir / filename
|
if hello_py.exists():
|
||||||
if filepath.exists():
|
hello_py.unlink()
|
||||||
filepath.unlink()
|
|
||||||
|
# Remove .python-version if created
|
||||||
|
python_version = project_dir / ".python-version"
|
||||||
|
if python_version.exists():
|
||||||
|
python_version.unlink()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def ensure_frontend(project_dir: Path, dry_run: bool = False) -> bool:
|
def ensure_frontend(project_dir: Path, dry_run: bool = False) -> bool:
|
||||||
"""Ensure frontend directory exists with a Vue project, run create-vue if needed."""
|
"""Ensure frontend directory exists, run create-vue if needed."""
|
||||||
frontend_dir = project_dir / "frontend"
|
frontend_dir = project_dir / "frontend"
|
||||||
package_json = frontend_dir / "package.json"
|
if frontend_dir.exists():
|
||||||
|
|
||||||
# Check for package.json, not just directory existence (empty dir shouldn't count)
|
|
||||||
if package_json.exists():
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Find JS runtime
|
# Find JS runtime
|
||||||
@@ -640,6 +508,13 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
project_dir = project_dir.resolve()
|
project_dir = project_dir.resolve()
|
||||||
dry_run = args.dry_run
|
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
|
# Create project directory if it doesn't exist
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
if dry_run:
|
if dry_run:
|
||||||
@@ -653,12 +528,12 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
if dry_run:
|
if dry_run:
|
||||||
print("\n🏃 DRY RUN MODE - no changes will be made\n")
|
print("\n🏃 DRY RUN MODE - no changes will be made\n")
|
||||||
|
|
||||||
# Step 1: Ensure frontend exists (do this first so cancellation doesn't leave partial setup)
|
# Step 1: Ensure Python project exists
|
||||||
if not ensure_frontend(project_dir, dry_run):
|
if not ensure_python_project(project_dir, dry_run):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Step 2: Ensure Python project exists
|
# Step 2: Ensure frontend exists
|
||||||
if not ensure_python_project(project_dir, dry_run):
|
if not ensure_frontend(project_dir, dry_run):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Detect module name
|
# Detect module name
|
||||||
@@ -668,15 +543,24 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
module_name = project_dir.name.replace("-", "_")
|
module_name = project_dir.name.replace("-", "_")
|
||||||
print(f"📦 Using module name from directory: {module_name}")
|
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
|
# Title for templates
|
||||||
project_title = module_name.replace("_", " ").title()
|
project_title = module_name.replace("_", " ").title()
|
||||||
|
|
||||||
print(f"📦 Module: {module_name}")
|
print(f"📦 Module: {module_name}")
|
||||||
|
print(f"🔌 Ports: Vite={vite_port}, Backend(dev)={backend_port}, Prod={prod_port}")
|
||||||
|
|
||||||
# Template variables
|
# Template variables
|
||||||
tpl_vars = {
|
tpl_vars = {
|
||||||
"MODULE_NAME": module_name,
|
"MODULE_NAME": module_name,
|
||||||
"PROJECT_TITLE": project_title,
|
"PROJECT_TITLE": project_title,
|
||||||
|
"VITE_PORT": vite_port,
|
||||||
|
"BACKEND_PORT": backend_port,
|
||||||
|
"PROD_PORT": prod_port,
|
||||||
}
|
}
|
||||||
|
|
||||||
module_dir = project_dir / module_name
|
module_dir = project_dir / module_name
|
||||||
@@ -717,14 +601,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
for dest_path, template_path in script_files:
|
for dest_path, template_path in script_files:
|
||||||
template = load_template(template_path)
|
template = load_template(template_path)
|
||||||
content = render_template(template, **tpl_vars)
|
content = render_template(template, **tpl_vars)
|
||||||
is_executable = dest_path.name == "devserver.py"
|
write_file(dest_path, content, overwrite=True, dry_run=dry_run)
|
||||||
write_file(
|
|
||||||
dest_path,
|
|
||||||
content,
|
|
||||||
overwrite=True,
|
|
||||||
dry_run=dry_run,
|
|
||||||
executable=is_executable,
|
|
||||||
)
|
|
||||||
|
|
||||||
# === Handle app module ===
|
# === Handle app module ===
|
||||||
if app_file:
|
if app_file:
|
||||||
@@ -747,10 +624,12 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# === Handle __main__.py ===
|
# === Handle __main__.py ===
|
||||||
main_file = module_dir / "__main__.py"
|
main_file = module_dir / "__main__.py"
|
||||||
if not main_file.exists():
|
if not main_file.exists() or ports_specified:
|
||||||
template = load_template("backend/__main__.py")
|
template = load_template("backend/__main__.py")
|
||||||
content = render_template(template, **tpl_vars)
|
content = render_template(template, **tpl_vars)
|
||||||
write_file(main_file, content, overwrite=False, dry_run=dry_run)
|
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 ===
|
# === Update vite.config.js/ts ===
|
||||||
frontend_dir = project_dir / "frontend"
|
frontend_dir = project_dir / "frontend"
|
||||||
@@ -766,16 +645,17 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
vite_config_js = frontend_dir / "vite.config.js"
|
vite_config_js = frontend_dir / "vite.config.js"
|
||||||
|
|
||||||
if vite_config_ts.exists():
|
if vite_config_ts.exists():
|
||||||
patch_vite_config(vite_config_ts, module_name, dry_run)
|
patch_vite_config(
|
||||||
|
vite_config_ts, module_name, backend_port, vite_port, dry_run
|
||||||
|
)
|
||||||
elif vite_config_js.exists():
|
elif vite_config_js.exists():
|
||||||
patch_vite_config(vite_config_js, module_name, dry_run)
|
patch_vite_config(
|
||||||
|
vite_config_js, module_name, backend_port, vite_port, dry_run
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print("⚠️ No vite.config.ts or vite.config.js found in frontend/")
|
print("⚠️ No vite.config.ts or vite.config.js found in frontend/")
|
||||||
print(" Run create-vue first to generate a Vite config to patch.")
|
print(" Run create-vue first to generate a Vite config to patch.")
|
||||||
|
|
||||||
# Patch Vue app with backend health check
|
|
||||||
patch_frontend_health_check(frontend_dir, dry_run)
|
|
||||||
|
|
||||||
# === Update pyproject.toml ===
|
# === Update pyproject.toml ===
|
||||||
pyproject_path = project_dir / "pyproject.toml"
|
pyproject_path = project_dir / "pyproject.toml"
|
||||||
if pyproject_path.exists():
|
if pyproject_path.exists():
|
||||||
@@ -797,27 +677,6 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
tomli_w.dump(updated, f)
|
tomli_w.dump(updated, f)
|
||||||
print(f"✅ Updated {pyproject_path}")
|
print(f"✅ Updated {pyproject_path}")
|
||||||
|
|
||||||
# === Add dependencies using uv ===
|
|
||||||
if dry_run:
|
|
||||||
print("[DRY RUN] Would run: uv add 'fastapi[standard]' fastapi-vue")
|
|
||||||
print("[DRY RUN] Would run: uv add --group dev httpx")
|
|
||||||
else:
|
|
||||||
print("📦 Adding dependencies...")
|
|
||||||
result = subprocess.run(
|
|
||||||
["uv", "add", "fastapi[standard]", "fastapi-vue"],
|
|
||||||
cwd=project_dir,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
print("⚠️ Failed to add main dependencies")
|
|
||||||
result = subprocess.run(
|
|
||||||
["uv", "add", "--group", "dev", "httpx"],
|
|
||||||
cwd=project_dir,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
print("⚠️ Failed to add dev dependencies")
|
|
||||||
|
|
||||||
# === Update .gitignore ===
|
# === Update .gitignore ===
|
||||||
gitignore_path = project_dir / ".gitignore"
|
gitignore_path = project_dir / ".gitignore"
|
||||||
gitignore_entry = f"{module_name}/frontend-build/"
|
gitignore_entry = f"{module_name}/frontend-build/"
|
||||||
@@ -842,15 +701,22 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("✅ Setup complete!")
|
print("✅ Setup complete!")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
print(f"""
|
||||||
|
Next steps:
|
||||||
|
|
||||||
# Show cd command only if project is not in current directory
|
1. Install dependencies:
|
||||||
cd_cmd = "" if project_dir == Path.cwd() else f"cd {project_dir}\n "
|
cd {project_dir}
|
||||||
script_name = module_name.replace("_", "-")
|
uv sync
|
||||||
|
|
||||||
message = SETUP_COMPLETE_MESSAGE.replace("CD_CMD", cd_cmd).replace(
|
2. Start development server:
|
||||||
"SCRIPT_NAME", script_name
|
uv run scripts/devserver.py
|
||||||
)
|
|
||||||
print(message)
|
3. Build for production:
|
||||||
|
uv build
|
||||||
|
|
||||||
|
4. Run production server:
|
||||||
|
uv run {module_name.replace("_", "-")}
|
||||||
|
""")
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -889,29 +755,60 @@ Examples:
|
|||||||
fastapi-vue-setup my-new-project Create a new project from scratch
|
fastapi-vue-setup my-new-project Create a new project from scratch
|
||||||
fastapi-vue-setup . Set up integration in current directory
|
fastapi-vue-setup . Set up integration in current directory
|
||||||
fastapi-vue-setup . --dry-run Preview what would be done
|
fastapi-vue-setup . --dry-run Preview what would be done
|
||||||
|
fastapi-vue-setup . --vite-port 3000 Update port configuration
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"project_dir",
|
"project_dir",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
default=None,
|
default=None,
|
||||||
help="Project directory (use . for current directory)",
|
help="Project directory (default: current directory)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--module-name", help="Python module name (auto-detected)")
|
parser.add_argument("--module-name", help="Python module name (auto-detected)")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dry-run", action="store_true", help="Show what would be done"
|
"--vite-port",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_VITE_PORT,
|
||||||
|
help=f"Vite dev server port (default: {DEFAULT_VITE_PORT})",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--version",
|
"--backend-port",
|
||||||
action="version",
|
type=int,
|
||||||
version=f"%(prog)s {__version__}",
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Handle default project directory with safety check
|
||||||
if args.project_dir is None:
|
if args.project_dir is None:
|
||||||
parser.print_help()
|
cwd = Path.cwd()
|
||||||
return 0
|
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)
|
return cmd_setup(args)
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ fastapi-vue-setup = "fastapi_vue_setup:main"
|
|||||||
[tool.hatch.version]
|
[tool.hatch.version]
|
||||||
source = "vcs"
|
source = "vcs"
|
||||||
|
|
||||||
[tool.hatch.build.hooks.vcs]
|
|
||||||
version-file = "_version.py"
|
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"]
|
include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""{{PROJECT_TITLE}} package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|||||||
@@ -1,53 +1,31 @@
|
|||||||
import argparse
|
"""Entry point for the application."""
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
|
|
||||||
import uvicorn
|
import sys
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
|
||||||
from uvicorn import Config, Server
|
|
||||||
|
|
||||||
from .APP_MODULE import APP_VAR
|
|
||||||
|
|
||||||
DEFAULT_PORT = 5080
|
|
||||||
|
|
||||||
|
|
||||||
def run_server(endpoints: list[dict], *, proxy="", devmode=False):
|
|
||||||
conf: dict[str, object] = {"app": "MODULE_NAME.APP_MODULE:APP_VAR"}
|
|
||||||
if proxy:
|
|
||||||
conf["proxy_headers"] = True
|
|
||||||
conf["forwarded_allow_ips"] = proxy
|
|
||||||
if devmode:
|
|
||||||
conf["reload"] = True
|
|
||||||
conf["reload_dirs"] = ["MODULE_NAME"]
|
|
||||||
APP_VAR.debug = True
|
|
||||||
|
|
||||||
if len(endpoints) > 1:
|
|
||||||
# Run separate servers for multiple endpoints
|
|
||||||
async def serve_all():
|
|
||||||
async with asyncio.TaskGroup() as tg:
|
|
||||||
for ep in endpoints:
|
|
||||||
tg.create_task(Server(Config(**conf, **ep)).serve())
|
|
||||||
|
|
||||||
asyncio.run(serve_all())
|
|
||||||
else:
|
|
||||||
uvicorn.run(**conf, **endpoints[0])
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.")
|
"""Run the FastAPI application using uvicorn."""
|
||||||
parser.add_argument(
|
import uvicorn
|
||||||
"endpoint",
|
|
||||||
nargs="?",
|
if len(sys.argv) > 1:
|
||||||
help=(
|
endpoint = sys.argv[1]
|
||||||
f"Endpoint (default: localhost:{DEFAULT_PORT}). "
|
if ":" in endpoint:
|
||||||
"Forms: host:port | :port | [ipv6]:port | ip | host | unix:/path.sock"
|
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",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
|
||||||
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
|
||||||
devmode = bool(os.getenv("FASTAPI_VUE_FRONTEND_URL"))
|
|
||||||
endpoints = parse_endpoint(args.endpoint, DEFAULT_PORT)
|
|
||||||
run_server(endpoints, proxy=proxy, devmode=devmode)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+15
-8
@@ -1,12 +1,17 @@
|
|||||||
|
"""Main FastAPI application."""
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi_vue import Frontend
|
from fastapi_vue import Frontend
|
||||||
|
|
||||||
# Vue Frontend static files
|
# Frontend static file server - configure options here
|
||||||
frontend = Frontend(
|
frontend = Frontend(
|
||||||
Path(__file__).with_name("frontend-build"), spa=True, cached=["/assets/"]
|
Path(__file__).parent / "frontend-build",
|
||||||
|
spa=True,
|
||||||
|
favicon="/assets/favicon",
|
||||||
|
cached=["/assets/"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -17,17 +22,19 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="PROJECT_TITLE", lifespan=lifespan)
|
app = FastAPI(title="{{PROJECT_TITLE}}", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
# Add API routes here...
|
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoint for the Vue demo app to verify the backend is running
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
|
"""Health check endpoint for the dev server."""
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
# Final catch-all route for frontend files (keep at end of file)
|
# Add your API routes here
|
||||||
|
# @app.get("/api/example")
|
||||||
|
# async def example():
|
||||||
|
# return {"message": "Hello World"}
|
||||||
|
|
||||||
|
|
||||||
frontend.route(app, "/")
|
frontend.route(app, "/")
|
||||||
|
|||||||
@@ -6,30 +6,32 @@
|
|||||||
* - Builds to the Python module's frontend-build directory
|
* - Builds to the Python module's frontend-build directory
|
||||||
*
|
*
|
||||||
* Environment variables (with defaults):
|
* Environment variables (with defaults):
|
||||||
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
|
* VITE_PORT=5173 - Vite dev server port
|
||||||
|
* VITE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
|
const backendUrl =
|
||||||
|
process.env.VITE_BACKEND_URL || "http://localhost:{{BACKEND_PORT}}";
|
||||||
|
const vitePort = parseInt(process.env.VITE_PORT || "{{VITE_PORT}}");
|
||||||
|
|
||||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
export default {
|
||||||
// Build proxy configuration for each path
|
name: "fastapi-vite",
|
||||||
const proxy = {}
|
config: () => ({
|
||||||
for (const path of paths) {
|
server: {
|
||||||
proxy[path] = {
|
host: "localhost",
|
||||||
target: backendUrl,
|
port: vitePort,
|
||||||
changeOrigin: false,
|
strictPort: true,
|
||||||
ws: true,
|
proxy: {
|
||||||
}
|
"/api": {
|
||||||
}
|
target: backendUrl,
|
||||||
|
changeOrigin: false,
|
||||||
return {
|
ws: true,
|
||||||
name: "fastapi-vite",
|
},
|
||||||
config: () => ({
|
|
||||||
server: { proxy },
|
|
||||||
build: {
|
|
||||||
outDir: "../MODULE_NAME/frontend-build",
|
|
||||||
emptyOutDir: true,
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
}
|
build: {
|
||||||
}
|
outDir: "../{{MODULE_NAME}}/frontend-build",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|||||||
Executable → Regular
+25
-64
@@ -2,22 +2,22 @@
|
|||||||
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run scripts/devserver.py [host:port] [--backend host:port]
|
uv run scripts/devserver.py [host:port]
|
||||||
|
|
||||||
The optional host:port argument sets where the Vite frontend listens.
|
The optional host:port argument sets where the Vite frontend listens.
|
||||||
Supported forms: host[:port], :port (all interfaces), or just port.
|
Supported forms: host[:port], :port (all interfaces), or just port.
|
||||||
The --backend option sets where the FastAPI backend listens (default: localhost:5180).
|
Backend always listens on localhost:{{BACKEND_PORT}}.
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
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.
|
FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sys import stderr
|
from sys import stderr
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -27,16 +27,10 @@ import httpx
|
|||||||
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
||||||
|
|
||||||
DEFAULT_HOST = "localhost"
|
DEFAULT_HOST = "localhost"
|
||||||
DEFAULT_VITE_PORT = 5173
|
DEFAULT_VITE_PORT = {{VITE_PORT}}
|
||||||
DEFAULT_BACKEND_PORT = 5180
|
BACKEND_PORT = {{BACKEND_PORT}}
|
||||||
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
||||||
|
|
||||||
EPILOG = """
|
|
||||||
scripts/devserver.py # Default ports on localhost
|
|
||||||
scripts/devserver.py 3000 # Vite on localhost:3000
|
|
||||||
scripts/devserver.py :3000 --backend 8000 # *:3000, localhost:8000
|
|
||||||
"""
|
|
||||||
|
|
||||||
BUN_BUG = """\
|
BUN_BUG = """\
|
||||||
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
|
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
|
||||||
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
|
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
|
||||||
@@ -45,15 +39,13 @@ BUN_BUG = """\
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def parse_endpoint(
|
def parse_endpoint(value: str | None) -> tuple[str | None, int, bool]:
|
||||||
value: str | None, default_port: int = DEFAULT_VITE_PORT
|
"""Parse an endpoint for Vite (no unix socket support).
|
||||||
) -> tuple[str | None, int, bool]:
|
|
||||||
"""Parse an endpoint for Vite or backend.
|
|
||||||
|
|
||||||
Returns (host, port, all_ifaces).
|
Returns (host, port, all_ifaces).
|
||||||
"""
|
"""
|
||||||
if not value:
|
if not value:
|
||||||
return DEFAULT_HOST, default_port, False
|
return DEFAULT_HOST, DEFAULT_VITE_PORT, False
|
||||||
|
|
||||||
# Port only (numeric) -> localhost:port
|
# Port only (numeric) -> localhost:port
|
||||||
if value.isdigit():
|
if value.isdigit():
|
||||||
@@ -72,12 +64,12 @@ def parse_endpoint(
|
|||||||
ipaddress.IPv6Address(value)
|
ipaddress.IPv6Address(value)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise SystemExit(f"Invalid IPv6 address '{value}': {e}") from e
|
raise SystemExit(f"Invalid IPv6 address '{value}': {e}") from e
|
||||||
return value, default_port, False
|
return value, DEFAULT_VITE_PORT, False
|
||||||
|
|
||||||
# Use urllib.parse for everything else
|
# Use urllib.parse for everything else
|
||||||
parsed = urlparse(f"//{value}")
|
parsed = urlparse(f"//{value}")
|
||||||
host = parsed.hostname or DEFAULT_HOST
|
host = parsed.hostname or DEFAULT_HOST
|
||||||
port = parsed.port or default_port
|
port = parsed.port or DEFAULT_VITE_PORT
|
||||||
|
|
||||||
return host, port, False
|
return host, port, False
|
||||||
|
|
||||||
@@ -94,7 +86,7 @@ def resolve_frontend_tools(
|
|||||||
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
|
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
result = find_js_runtime() # noqa # type: ignore
|
result = find_js_runtime()
|
||||||
if result is None:
|
if result is None:
|
||||||
if not os.environ.get("JS_RUNTIME"):
|
if not os.environ.get("JS_RUNTIME"):
|
||||||
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
|
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
|
||||||
@@ -114,17 +106,12 @@ def resolve_frontend_tools(
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_cmd = [tool, *install_args[name]]
|
install_cmd = [tool, *install_args[name]]
|
||||||
dev_cmd = [
|
dev_cmd = [tool, *dev_args[name], "--port", str(vite_port)]
|
||||||
tool,
|
|
||||||
*dev_args[name],
|
|
||||||
"--clearScreen=false",
|
|
||||||
f"--port={vite_port}",
|
|
||||||
]
|
|
||||||
|
|
||||||
if all_ifaces:
|
if all_ifaces:
|
||||||
dev_cmd.append("--host")
|
dev_cmd.append("--host")
|
||||||
elif vite_host and vite_host != "localhost":
|
elif vite_host:
|
||||||
dev_cmd.append(f"--host={vite_host}")
|
dev_cmd.extend(["--host", vite_host])
|
||||||
|
|
||||||
if name == "bun":
|
if name == "bun":
|
||||||
stderr.write(BUN_BUG)
|
stderr.write(BUN_BUG)
|
||||||
@@ -132,13 +119,13 @@ def resolve_frontend_tools(
|
|||||||
return install_cmd, dev_cmd, name
|
return install_cmd, dev_cmd, name
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_backend(backend_host: str, backend_port: int):
|
async def wait_for_backend():
|
||||||
"""Wait for the backend to be ready by polling the health endpoint."""
|
"""Wait for the backend to be ready by polling the health endpoint."""
|
||||||
max_attempts = 50
|
max_attempts = 50
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
try:
|
try:
|
||||||
await client.get(f"http://{backend_host}:{backend_port}", timeout=1.0)
|
await client.get(f"http://localhost:{BACKEND_PORT}", timeout=1.0)
|
||||||
stderr.write("✓ Backend ready!\n")
|
stderr.write("✓ Backend ready!\n")
|
||||||
return True
|
return True
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
@@ -168,11 +155,7 @@ async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> Non
|
|||||||
|
|
||||||
|
|
||||||
async def run_devserver(
|
async def run_devserver(
|
||||||
vite_host: str | None,
|
vite_host: str | None, vite_port: int, all_ifaces: bool
|
||||||
vite_port: int,
|
|
||||||
all_ifaces: bool,
|
|
||||||
backend_host: str,
|
|
||||||
backend_port: int,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run the development server with install, backend, and frontend."""
|
"""Run the development server with install, backend, and frontend."""
|
||||||
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(
|
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(
|
||||||
@@ -184,7 +167,7 @@ async def run_devserver(
|
|||||||
f"http://{vite_host or 'localhost'}:{vite_port}"
|
f"http://{vite_host or 'localhost'}:{vite_port}"
|
||||||
)
|
)
|
||||||
# Tell Vite where the backend is (for proxying /api requests)
|
# Tell Vite where the backend is (for proxying /api requests)
|
||||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
|
os.environ["VITE_BACKEND_URL"] = f"http://localhost:{BACKEND_PORT}"
|
||||||
cwd = str(Path(__file__).parent.parent)
|
cwd = str(Path(__file__).parent.parent)
|
||||||
frontend_cwd = str(FRONTEND_PATH)
|
frontend_cwd = str(FRONTEND_PATH)
|
||||||
|
|
||||||
@@ -204,11 +187,11 @@ async def run_devserver(
|
|||||||
# Start backend (concurrent with install)
|
# Start backend (concurrent with install)
|
||||||
backend_cmd = [
|
backend_cmd = [
|
||||||
"uvicorn",
|
"uvicorn",
|
||||||
"MODULE_NAME.app:app",
|
"{{MODULE_NAME}}.app:app",
|
||||||
"--host",
|
"--host",
|
||||||
backend_host,
|
"localhost",
|
||||||
"--port",
|
"--port",
|
||||||
str(backend_port),
|
str(BACKEND_PORT),
|
||||||
"--reload",
|
"--reload",
|
||||||
]
|
]
|
||||||
stderr.write(f">>> {' '.join(backend_cmd)}\n")
|
stderr.write(f">>> {' '.join(backend_cmd)}\n")
|
||||||
@@ -217,7 +200,7 @@ async def run_devserver(
|
|||||||
# Wait for install to complete and backend to be ready
|
# Wait for install to complete and backend to be ready
|
||||||
install_task = asyncio.create_task(install_proc.wait(), name="install")
|
install_task = asyncio.create_task(install_proc.wait(), name="install")
|
||||||
backend_ready_task = asyncio.create_task(
|
backend_ready_task = asyncio.create_task(
|
||||||
wait_for_backend(backend_host, backend_port), name="backend_ready"
|
wait_for_backend(), name="backend_ready"
|
||||||
)
|
)
|
||||||
|
|
||||||
done, pending = await asyncio.wait(
|
done, pending = await asyncio.wait(
|
||||||
@@ -274,33 +257,11 @@ async def run_devserver(
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
hostport = sys.argv[1] if len(sys.argv) > 1 else None
|
||||||
description="Run Vite and FastAPI development servers",
|
vite_host, vite_port, all_ifaces = parse_endpoint(hostport)
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog=EPILOG,
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"frontend",
|
|
||||||
nargs="?",
|
|
||||||
metavar="host:port",
|
|
||||||
help="Vite frontend endpoint (default: localhost:5173)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--backend",
|
|
||||||
metavar="host:port",
|
|
||||||
help="FastAPI backend endpoint (default: localhost:5180)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
vite_host, vite_port, all_ifaces = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
|
|
||||||
backend_host, backend_port, _ = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
|
|
||||||
# Backend host defaults to localhost (never None)
|
|
||||||
backend_host = backend_host or DEFAULT_HOST
|
|
||||||
|
|
||||||
with contextlib.suppress(KeyboardInterrupt):
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
asyncio.run(
|
asyncio.run(run_devserver(vite_host, vite_port, all_ifaces))
|
||||||
run_devserver(vite_host, vite_port, all_ifaces, backend_host, backend_port)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sys import stderr
|
from sys import stderr
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
|
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ class CustomBuildHook(BuildHookInterface):
|
|||||||
super().initialize(version, build_data)
|
super().initialize(version, build_data)
|
||||||
stderr.write(">>> Building the frontend\n")
|
stderr.write(">>> Building the frontend\n")
|
||||||
|
|
||||||
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
|
install_cmd, build_cmd = find_build_tool()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run(install_cmd, cwd="frontend")
|
run(install_cmd, cwd="frontend")
|
||||||
|
|||||||
@@ -51,9 +51,8 @@ def find_build_tool():
|
|||||||
"npm": ("install",),
|
"npm": ("install",),
|
||||||
"bun": ("--bun", "install"),
|
"bun": ("--bun", "install"),
|
||||||
}
|
}
|
||||||
# Run vite directly for deno to avoid npm-run-all2/run-p issues
|
|
||||||
build = {
|
build = {
|
||||||
"deno": ("run", "-A", "npm:vite", "build"),
|
"deno": ("task", "build"),
|
||||||
"npm": ("run", "build"),
|
"npm": ("run", "build"),
|
||||||
"bun": ("--bun", "run", "build"),
|
"bun": ("--bun", "run", "build"),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user