Compare commits

..
13 Commits
6 changed files with 219 additions and 487 deletions
+1
View File
@@ -3,3 +3,4 @@
*.lock
__pycache__/
dist/
/_version.py
+142 -445
View File
@@ -15,18 +15,20 @@ import os
import re
import subprocess
import sys
import tomllib
from pathlib import Path
from textwrap import indent
import tomlkit
import tomli_w
try:
from _version import __version__
except ImportError:
__version__ = "0.0.0+unknown"
# Template directory
TEMPLATE_DIR = Path(__file__).parent / "template"
# Marker comment indicating file can be auto-upgraded
# Users should remove this line to prevent automatic updates
UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup"
# pyproject.toml additions for patched projects
PYPROJECT_ADDITIONS = {
"tool": {
@@ -51,7 +53,9 @@ PYPROJECT_ADDITIONS = {
# Frontend instantiation block for patching existing apps
FRONTEND_BLOCK = """
# Vue Frontend static files
frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"])
frontend = Frontend(
Path(__file__).with_name("frontend-build"), spa=True, cached=["/assets/"]
)
"""
# TypeScript health check script for Vue components
@@ -127,7 +131,8 @@ def find_module_name(project_dir: Path) -> str | None:
if not pyproject.exists():
return None
data = tomlkit.parse(pyproject.read_text())
with open(pyproject, "rb") as f:
data = tomllib.load(f)
if "project" in data and "name" in data["project"]:
name = data["project"]["name"]
@@ -136,18 +141,10 @@ def find_module_name(project_dir: Path) -> str | None:
return None
def find_fastapi_app(
module_dir: Path, project_dir: Path | None = None
) -> tuple[Path, str] | 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.
Search order:
1. Common app files in module_dir (app.py, main.py, etc.)
2. All .py files in module_dir
3. Subpackage indicated by CLI entrypoint in pyproject.toml
4. Follow re-exports in __init__.py files
"""
# Common app file names to check first
candidates = ["app.py", "main.py", "server.py", "api.py", "__init__.py"]
@@ -160,153 +157,13 @@ def find_fastapi_app(
if result:
return path, result
# Then check all .py files in module_dir
# 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
# Try to find app via CLI entrypoint in pyproject.toml
if project_dir:
result = _find_app_via_entrypoint(module_dir, project_dir)
if result:
return result
return None
def _find_app_via_entrypoint(module_dir: Path, project_dir: Path) -> tuple[Path, str] | None:
"""Find FastAPI app by following the CLI entrypoint in pyproject.toml.
If pyproject.toml has a script like `myapp = "myapp.subpkg.__main__:main"`,
look in myapp/subpkg/ for the app (checking __init__.py exports and common files).
"""
pyproject = project_dir / "pyproject.toml"
if not pyproject.exists():
return None
try:
data = tomlkit.parse(pyproject.read_text())
except Exception:
return None
scripts = data.get("project", {}).get("scripts", {})
if not scripts:
return None
module_name = module_dir.name
# Find script entries that reference this module
for script_name, entry in scripts.items():
if not isinstance(entry, str):
continue
# Parse entry like "module.subpkg.__main__:main"
if ":" not in entry:
continue
module_path, _ = entry.rsplit(":", 1)
parts = module_path.split(".")
# Check if this entry starts with our module
if not parts or parts[0] != module_name:
continue
# If there's a subpackage (e.g., module.fastapi.__main__), check there
if len(parts) >= 2:
# Build path to subpackage (exclude __main__ or similar)
subpkg_parts = [p for p in parts[1:] if not p.startswith("_")]
if subpkg_parts:
subpkg_dir = module_dir / "/".join(subpkg_parts)
if subpkg_dir.is_dir():
result = _find_app_in_subpackage(subpkg_dir)
if result:
return result
return None
def _find_app_in_subpackage(subpkg_dir: Path) -> tuple[Path, str] | None:
"""Find FastAPI app in a subpackage, following __init__.py exports."""
# First check __init__.py for re-exports like `from .mainapp import app`
init_file = subpkg_dir / "__init__.py"
if init_file.exists():
result = _follow_init_reexport(init_file, subpkg_dir)
if result:
return result
# Check common app file names in subpackage
for name in ["app.py", "main.py", "mainapp.py", "server.py", "api.py"]:
path = subpkg_dir / name
if path.exists():
result = _find_app_in_file(path)
if result:
return path, result
return None
def _find_existing_cli_entrypoint(project_dir: Path, module_name: str) -> str | None:
"""Check if pyproject.toml already has a CLI entrypoint for this module.
Returns the entrypoint string if found, None otherwise.
"""
pyproject = project_dir / "pyproject.toml"
if not pyproject.exists():
return None
try:
data = tomlkit.parse(pyproject.read_text())
except Exception:
return None
scripts = data.get("project", {}).get("scripts", {})
if not scripts:
return None
# Look for any script that references our module
for script_name, entry in scripts.items():
if isinstance(entry, str) and entry.startswith(f"{module_name}."):
return f"{script_name} = \"{entry}\""
return None
def _follow_init_reexport(init_file: Path, subpkg_dir: Path) -> tuple[Path, str] | None:
"""Follow a re-export in __init__.py to find the actual app file.
Looks for patterns like:
- from .mainapp import app
- from module.subpkg.mainapp import app
"""
try:
content = init_file.read_text()
except Exception:
return None
# Look for: from .module import app (or similar variable names)
# Pattern matches: from .mainapp import app, application, etc.
pattern = r"from\s+\.(\w+)\s+import\s+(\w+)"
for match in re.finditer(pattern, content):
module_name, var_name = match.groups()
if var_name.lower() in ("app", "application", "api"):
target_file = subpkg_dir / f"{module_name}.py"
if target_file.exists():
# Verify the app is actually there
app_var = _find_app_in_file(target_file)
if app_var:
return target_file, app_var
# Also check for absolute imports: from pkg.subpkg.module import app
abs_pattern = r"from\s+[\w.]+\.(\w+)\s+import\s+(\w+)"
for match in re.finditer(abs_pattern, content):
module_name, var_name = match.groups()
if var_name.lower() in ("app", "application", "api"):
target_file = subpkg_dir / f"{module_name}.py"
if target_file.exists():
app_var = _find_app_in_file(target_file)
if app_var:
return target_file, app_var
return None
@@ -348,15 +205,19 @@ def patch_app_file(
print(f"❌ Cannot patch {path} - file not found")
return False
original_content = path.read_text()
content = path.read_text()
marker = "from fastapi_vue import Frontend"
if marker in original_content:
print(f" {path} (already patched)")
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 = original_content.split("\n")
lines = content.split("\n")
import_line = "from fastapi_vue import Frontend"
route_line = f'frontend.route({app_var}, "/")'
@@ -374,11 +235,6 @@ def patch_app_file(
# Stop at first non-import, non-comment, non-empty line after imports
break
# Check if we found imports to insert after
if last_import_idx == 0 and not lines[0].strip().startswith(("import ", "from ")):
print(f"⚠️ Skipping {path} (no imports found to patch)")
return False
# Insert imports after last import, then frontend instantiation
if not has_pathlib:
lines.insert(last_import_idx + 1, "from pathlib import Path")
@@ -388,33 +244,35 @@ def patch_app_file(
# Append route at end
lines.append("")
lines.append("# Serve the Vue frontend (needs to be last if SPA catch-all is used)")
lines.append(route_line)
content = "\n".join(lines)
# Try to patch lifespan function - insert await frontend.load() before yield
# Try to patch lifespan function
lifespan_patched = False
# Look for yield inside an async def lifespan function
# Find the yield statement and insert before it
yield_pattern = r"^([ \t]+)(yield\b)"
yield_match = re.search(yield_pattern, content, re.MULTILINE)
if yield_match:
indent = yield_match.group(1)
insert_pos = yield_match.start()
# 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
# Check if content actually changed
if content == original_content:
print(f"⚠️ Skipping {path} (no changes needed)")
return False
if dry_run:
print(f"[DRY RUN] Would patch {path}")
return True
path.write_text(content)
print(f"✅ Patched {path}")
@@ -450,17 +308,21 @@ def patch_vite_config(
print(f"❌ Cannot patch {path} - file not found")
return False
original_content = path.read_text()
content = path.read_text()
marker = "vite-plugin-fastapi"
if marker in original_content:
print(f" {path} (already patched)")
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 = original_content.split("\n")
lines = content.split("\n")
new_lines = []
import_inserted = False
@@ -492,18 +354,6 @@ def patch_vite_config(
if match:
insert_pos = match.end()
content = content[:insert_pos] + "\n fastapiVue()," + content[insert_pos:]
else:
print(f"⚠️ Skipping {path} (no plugins array found)")
return False
# Check if content actually changed
if content == original_content:
print(f"⚠️ Skipping {path} (no changes needed)")
return False
if dry_run:
print(f"[DRY RUN] Would patch {path}")
return True
path.write_text(content)
print(f"✅ Patched {path}")
@@ -539,14 +389,16 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
print("⚠️ No Vue file found to patch, skipping frontend health check")
return False
original_content = target_file.read_text()
content = target_file.read_text()
# Check if already patched
if "/api/health" in original_content:
print(f" {target_file} (already patched)")
if "/api/health" in content:
print(f" Skipping {target_file} (already patched)")
return False
content = original_content
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
@@ -558,97 +410,49 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
# Insert script addition before </script>
script_end_match = re.search(r"</script>", content)
if not script_end_match:
print(f"⚠️ Skipping {target_file} (no </script> tag found)")
return False
insert_pos = script_end_match.start()
content = content[:insert_pos] + script_addition + content[insert_pos:]
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): only patch if it's the default "You did it!" template
# 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:
print(
f"⚠️ Skipping {target_file} (no </h3> tag found for status insertion)"
)
return False
before, after = content[:h3_close], content[h3_close:]
content = f"{before}{indent(STATUS_SPAN_TEMPLATE, ' ')}{after}"
else:
# Minimal App.vue - only patch if it contains the default welcome message
if "<h1>You did it!</h1>" not in content:
print(f"⚠️ Skipping {target_file} (not a default Vue template)")
return False
# Insert before the </p> tag
# Minimal App.vue - insert before the last </p> before </template>
template_end = content.find("</template>")
if template_end == -1:
print(f"⚠️ Skipping {target_file} (no </template> tag found)")
return False
# Find </p> before </template>
last_p = content.rfind("</p>", 0, template_end)
if last_p == -1:
print(f"⚠️ Skipping {target_file} (no </p> tag found for status insertion)")
return False
before, after = content[:last_p], content[last_p:]
content = f"{before}{STATUS_SPAN_TEMPLATE}{after}"
# Check if content actually changed
if content == original_content:
print(f"⚠️ Skipping {target_file} (no changes needed)")
return False
if dry_run:
print(f"[DRY RUN] Would patch {target_file}")
return True
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
# Track .new.py files written during setup (for merge notification)
_new_files_written: list[tuple[Path, Path]] = []
def write_file(
path: Path,
content: str,
overwrite: bool = True,
dry_run: bool = False,
executable: bool = False,
fallback_path: Path | None = None,
) -> bool:
"""Write content to a file, handling existing files and dry-run.
If fallback_path is provided and the file exists without the upgrade marker,
the content will be written to fallback_path instead of being skipped.
"""
"""Write content to a file, handling existing files and dry-run."""
exists = path.exists()
if exists and not overwrite:
print(f" Skipping {path} (exists)")
print(f" Skipping {path} (exists)")
return False
# Check if content is the same
if exists:
existing_content = path.read_text()
if existing_content == content:
print(f"✔️ {path} (already up to date)")
return False
# If overwrite requested but file doesn't have upgrade marker
if overwrite and UPGRADE_MARKER not in existing_content:
if fallback_path is not None:
# Write to fallback path instead
return _write_fallback_file(
path, fallback_path, content, dry_run, executable
)
print(f"️ Skipping {path} (customized by user)")
return False
if dry_run:
action = "overwrite" if exists else "create"
print(f"[DRY RUN] Would {action} {path}")
@@ -663,113 +467,47 @@ def write_file(
return True
def _write_fallback_file(
original_path: Path,
fallback_path: Path,
content: str,
dry_run: bool,
executable: bool,
) -> bool:
"""Write content to a fallback .new.py file when original can't be overwritten."""
# Check if fallback already has same content
if fallback_path.exists():
if fallback_path.read_text() == content:
print(f"✔️ {fallback_path} (already up to date)")
return False
def merge_pyproject(data: dict, additions: dict, module_name: str) -> dict:
"""Merge additions into pyproject.toml data."""
result = data.copy()
if dry_run:
print(f"[DRY RUN] Would create {fallback_path} (original customized by user)")
_new_files_written.append((fallback_path, original_path))
return True
fallback_path.parent.mkdir(parents=True, exist_ok=True)
fallback_path.write_text(content)
if executable and sys.platform != "win32":
fallback_path.chmod(fallback_path.stat().st_mode | 0o111)
print(f"✅ Created {fallback_path} (original customized by user)")
_new_files_written.append((fallback_path, original_path))
return True
def merge_pyproject(
data: tomlkit.TOMLDocument, additions: dict, module_name: str
) -> tomlkit.TOMLDocument:
"""Merge additions into pyproject.toml data, preserving comments and existing values."""
# Ensure hatchling build system is configured
if "build-system" not in data:
data["build-system"] = tomlkit.table()
if "build-system" not in result:
result["build-system"] = {}
result["build-system"]["requires"] = ["hatchling"]
result["build-system"]["build-backend"] = "hatchling.build"
# Add hatchling to requires if not present, preserving existing requires
if "requires" not in data["build-system"]:
data["build-system"]["requires"] = ["hatchling"]
else:
requires = list(data["build-system"]["requires"])
if not any(r.startswith("hatchling") for r in requires):
requires.insert(0, "hatchling")
data["build-system"]["requires"] = requires
if "build-backend" not in data["build-system"]:
data["build-system"]["build-backend"] = "hatchling.build"
# Ensure project table exists
if "project" not in data:
data["project"] = tomlkit.table()
# Ensure Python version is at least 3.11 (required by fastapi-vue)
if "requires-python" in data["project"]:
req = data["project"]["requires-python"]
# Parse minimum version from strings like ">=3.10" or ">=3.9,<4"
import re
match = re.search(r">=\s*(\d+)\.(\d+)", req)
if match:
major, minor = int(match.group(1)), int(match.group(2))
if major < 3 or (major == 3 and minor < 11):
data["project"]["requires-python"] = ">=3.11"
else:
data["project"]["requires-python"] = ">=3.11"
# Add dependencies
if "project" not in result:
result["project"] = {}
if "dependencies" not in result["project"]:
result["project"]["dependencies"] = []
# Add hatch build config
if "tool" not in data:
data["tool"] = tomlkit.table()
if "hatch" not in data["tool"]:
data["tool"]["hatch"] = tomlkit.table()
if "build" not in data["tool"]["hatch"]:
data["tool"]["hatch"]["build"] = tomlkit.table()
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 = data["tool"]["hatch"]["build"]
hatch_additions = 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"] = [
a.replace("MODULE_NAME", module_name) for a in hatch_build["artifacts"]
]
result["tool"]["hatch"]["build"]["only-packages"] = hatch_build["only-packages"]
# Set packages if not already set
if "packages" not in hatch_build:
hatch_build["packages"] = [
p.replace("MODULE_NAME", module_name) for p in hatch_additions["packages"]
]
if "targets" not in result["tool"]["hatch"]["build"]:
result["tool"]["hatch"]["build"]["targets"] = {}
# Set artifacts if not already set
if "artifacts" not in hatch_build:
hatch_build["artifacts"] = [
a.replace("MODULE_NAME", module_name) for a in hatch_additions["artifacts"]
]
result["tool"]["hatch"]["build"]["targets"]["sdist"] = hatch_build["targets"][
"sdist"
]
# Set only-packages if not already set
if "only-packages" not in hatch_build:
hatch_build["only-packages"] = hatch_additions["only-packages"]
# Add sdist target with custom hook
if "targets" not in hatch_build:
hatch_build["targets"] = tomlkit.table()
if "sdist" not in hatch_build["targets"]:
hatch_build["targets"]["sdist"] = tomlkit.table()
if "hooks" not in hatch_build["targets"]["sdist"]:
hatch_build["targets"]["sdist"]["hooks"] = tomlkit.table()
if "custom" not in hatch_build["targets"]["sdist"]["hooks"]:
hatch_build["targets"]["sdist"]["hooks"]["custom"] = tomlkit.table()
if "path" not in hatch_build["targets"]["sdist"]["hooks"]["custom"]:
hatch_build["targets"]["sdist"]["hooks"]["custom"]["path"] = hatch_additions[
"targets"
]["sdist"]["hooks"]["custom"]["path"]
return data
return result
# =============================================================================
@@ -946,7 +684,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
fastapi_vue_scripts = scripts_dir / "fastapi-vue"
# Find existing FastAPI app
app_info = find_fastapi_app(module_dir, project_dir) if module_dir.exists() else None
app_info = find_fastapi_app(module_dir) if module_dir.exists() else None
if app_info:
app_file, app_var = app_info
@@ -967,40 +705,27 @@ def cmd_setup(args: argparse.Namespace) -> int:
module_dir.mkdir(parents=True)
# === Install scripts (always update our own scripts) ===
# util.py and build-frontend.py are internal and always overwritten
# devserver.py can be customized, so use fallback if needed
internal_script_files = [
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 internal_script_files:
for dest_path, template_path in script_files:
template = load_template(template_path)
content = render_template(template, **tpl_vars)
is_executable = dest_path.name == "devserver.py"
write_file(
dest_path,
content,
overwrite=True,
dry_run=dry_run,
executable=is_executable,
)
# devserver.py - use fallback if customized by user
devserver_path = scripts_dir / "devserver.py"
devserver_fallback = scripts_dir / "devserver.new.py"
template = load_template("scripts/devserver.py")
content = render_template(template, **tpl_vars)
write_file(
devserver_path,
content,
overwrite=True,
dry_run=dry_run,
executable=True,
fallback_path=devserver_fallback,
)
# === Handle app module ===
if app_file:
# Existing app: patch with import, route, and try to patch lifespan
@@ -1021,27 +746,11 @@ def cmd_setup(args: argparse.Namespace) -> int:
write_file(app_file_path, content, overwrite=False, dry_run=dry_run)
# === Handle __main__.py ===
# Skip if project already has a CLI entrypoint in pyproject.toml
existing_cli = _find_existing_cli_entrypoint(project_dir, module_name)
if existing_cli:
print(f"️ Using existing CLI entrypoint: {existing_cli}")
else:
main_file = module_dir / "__main__.py"
main_fallback = module_dir / "__main__.new.py"
main_file = module_dir / "__main__.py"
if not main_file.exists():
template = load_template("backend/__main__.py")
main_content = render_template(template, **tpl_vars)
# Use write_file with fallback - it handles all cases:
# - File doesn't exist: create it
# - File exists with marker: update it
# - File exists without marker: write to fallback
write_file(
main_file,
main_content,
overwrite=True,
dry_run=dry_run,
fallback_path=main_fallback,
)
content = render_template(template, **tpl_vars)
write_file(main_file, content, overwrite=False, dry_run=dry_run)
# === Update vite.config.js/ts ===
frontend_dir = project_dir / "frontend"
@@ -1070,43 +779,39 @@ def cmd_setup(args: argparse.Namespace) -> int:
# === Update pyproject.toml ===
pyproject_path = project_dir / "pyproject.toml"
if pyproject_path.exists():
old_content = pyproject_path.read_text()
data = tomlkit.parse(old_content)
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name)
# Only add script entry if project doesn't already have scripts
if "scripts" not in updated["project"] or not updated["project"]["scripts"]:
updated["project"]["scripts"] = tomlkit.table()
script_name = module_name.replace("_", "-")
updated["project"]["scripts"][script_name] = f"{module_name}.__main__:main"
# 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"
# Check if content actually changed
new_content = tomlkit.dumps(updated)
if new_content == old_content:
print(f"✔️ {pyproject_path} (already up to date)")
elif dry_run:
if dry_run:
print(f"[DRY RUN] Would update {pyproject_path}")
else:
pyproject_path.write_text(new_content)
with open(pyproject_path, "wb") as f:
tomli_w.dump(updated, f)
print(f"✅ Updated {pyproject_path}")
# === Add dependencies using uv ===
if dry_run:
print("[DRY RUN] Would run: uv add -U 'fastapi[standard]' fastapi-vue")
print("[DRY RUN] Would run: uv add -U --group dev httpx")
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", "-U", "fastapi[standard]", "fastapi-vue"],
["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", "-U", "--group", "dev", "httpx"],
["uv", "add", "--group", "dev", "httpx"],
cwd=project_dir,
check=False,
)
@@ -1115,18 +820,18 @@ def cmd_setup(args: argparse.Namespace) -> int:
# === Update .gitignore ===
gitignore_path = project_dir / ".gitignore"
gitignore_entry = f"/{module_name}/frontend-build"
gitignore_entry = f"{module_name}/frontend-build/"
if gitignore_path.exists():
gitignore_content = gitignore_path.read_bytes()
if b"frontend-build" in gitignore_content:
print("✔️ .gitignore (frontend-build already ignored)")
elif dry_run:
print(f"[DRY RUN] Would add {gitignore_entry} to .gitignore")
else:
nl = b"\r\n" if b"\r\n" in gitignore_content else b"\n"
suffix = b"" if gitignore_content.endswith(nl) else nl
gitignore_path.write_bytes(gitignore_content + suffix + gitignore_entry.encode() + nl)
print(f"✅ Added {gitignore_entry} to .gitignore")
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:
@@ -1147,19 +852,6 @@ def cmd_setup(args: argparse.Namespace) -> int:
)
print(message)
# Show merge note if any .new.py files were written
if _new_files_written:
print()
print(
"⚠️ Note: Some files could not be auto-upgraded because you customized them."
)
print(" Please manually merge the following files:")
for new_file, original_file in _new_files_written:
print(f"{new_file.name}{original_file.name}")
print()
# Clear the list for potential subsequent runs
_new_files_written.clear()
return 0
@@ -1209,6 +901,11 @@ Examples:
parser.add_argument(
"--dry-run", action="store_true", help="Show what would be done"
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {__version__}",
)
args = parser.parse_args()
+4 -1
View File
@@ -9,7 +9,7 @@ description = "Tool to create or patch FastAPI+Vue projects with integrated buil
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"tomlkit>=0.12.0",
"tomli-w>=1.0.0",
]
[project.urls]
@@ -22,6 +22,9 @@ fastapi-vue-setup = "fastapi_vue_setup:main"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.hooks.vcs]
version-file = "_version.py"
[tool.hatch.build.targets.wheel]
include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"]
-1
View File
@@ -1,4 +1,3 @@
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
import argparse
import asyncio
import os
+4 -2
View File
@@ -5,7 +5,9 @@ from fastapi import FastAPI
from fastapi_vue import Frontend
# Vue Frontend static files
frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"])
frontend = Frontend(
Path(__file__).with_name("frontend-build"), spa=True, cached=["/assets/"]
)
@asynccontextmanager
@@ -27,5 +29,5 @@ async def health_check():
return {"status": "ok"}
# Serve the Vue frontend (needs to be last if SPA catch-all is used)
# Final catch-all route for frontend files (keep at end of file)
frontend.route(app, "/")
+68 -38
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env -S uv run
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
Usage:
@@ -17,15 +16,17 @@ Environment:
import argparse
import asyncio
import contextlib
import ipaddress
import os
from pathlib import Path
from sys import stderr
from urllib.parse import urlparse
import httpx
from fastapi_vue.hostutil import parse_endpoint
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
DEFAULT_HOST = "localhost"
DEFAULT_VITE_PORT = 5173
DEFAULT_BACKEND_PORT = 5180
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
@@ -44,8 +45,45 @@ BUN_BUG = """\
"""
def parse_endpoint(
value: str | None, default_port: int = DEFAULT_VITE_PORT
) -> tuple[str | None, int, bool]:
"""Parse an endpoint for Vite or backend.
Returns (host, port, all_ifaces).
"""
if not value:
return DEFAULT_HOST, default_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_port, False
# Use urllib.parse for everything else
parsed = urlparse(f"//{value}")
host = parsed.hostname or DEFAULT_HOST
port = parsed.port or default_port
return host, port, False
def resolve_frontend_tools(
vite_port: int, all_ifaces: bool
vite_host: str | None, vite_port: int, all_ifaces: bool
) -> tuple[list[str], list[str], str]:
"""Resolve frontend install and dev commands.
@@ -85,6 +123,8 @@ def resolve_frontend_tools(
if all_ifaces:
dev_cmd.append("--host")
elif vite_host and vite_host != "localhost":
dev_cmd.append(f"--host={vite_host}")
if name == "bun":
stderr.write(BUN_BUG)
@@ -92,15 +132,13 @@ def resolve_frontend_tools(
return install_cmd, dev_cmd, name
async def wait_for_backend(host: str, port: int):
async def wait_for_backend(backend_host: str, backend_port: int):
"""Wait for the backend to be ready by polling the health endpoint."""
max_attempts = 50
url = f"http://{host}:{port}"
async with httpx.AsyncClient() as client:
for attempt in range(max_attempts):
try:
await client.get(url, timeout=1.0)
await client.get(f"http://{backend_host}:{backend_port}", timeout=1.0)
stderr.write("✓ Backend ready!\n")
return True
except httpx.RequestError:
@@ -130,29 +168,23 @@ async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> Non
async def run_devserver(
vite_host: str | None,
vite_port: int,
all_ifaces: bool,
backend_host: str,
backend_port: int,
) -> None:
"""Run the development server with install, backend, and frontend."""
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port, all_ifaces)
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://localhost:{vite_port}"
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["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
backend_cmd = [
"uvicorn",
"MODULE_NAME.app:app",
"--host",
backend_host,
"--port",
str(backend_port),
"--reload",
]
cwd = str(Path(__file__).parent.parent)
frontend_cwd = str(FRONTEND_PATH)
@@ -170,6 +202,15 @@ async def run_devserver(
await asyncio.sleep(0.1)
# Start backend (concurrent with install)
backend_cmd = [
"uvicorn",
"MODULE_NAME.app:app",
"--host",
backend_host,
"--port",
str(backend_port),
"--reload",
]
stderr.write(f">>> {' '.join(backend_cmd)}\n")
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
@@ -251,26 +292,15 @@ def main():
)
args = parser.parse_args()
# parse_endpoint returns list of dicts with host/port or uds keys
# Multiple entries means bind all interfaces (IPv4 + IPv6)
vite_endpoints = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
backend_endpoints = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
# Vite doesn't support unix sockets
if "uds" in vite_endpoints[0]:
stderr.write("┃ ⚠️ Unix sockets not supported for frontend\n")
raise SystemExit(1)
if "uds" in backend_endpoints[0]:
stderr.write("┃ ⚠️ Unix sockets not supported for backend\n")
raise SystemExit(1)
vite_port = vite_endpoints[0]["port"]
all_ifaces = len(vite_endpoints) > 1
backend_host = backend_endpoints[0]["host"]
backend_port = backend_endpoints[0]["port"]
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):
asyncio.run(run_devserver(vite_port, all_ifaces, backend_host, backend_port))
asyncio.run(
run_devserver(vite_host, vite_port, all_ifaces, backend_host, backend_port)
)
if __name__ == "__main__":