diff --git a/fastapi_vue_setup.py b/fastapi_vue_setup.py
index b0ac729..983a8c1 100644
--- a/fastapi_vue_setup.py
+++ b/fastapi_vue_setup.py
@@ -15,11 +15,10 @@ import os
import re
import subprocess
import sys
-import tomllib
from pathlib import Path
from textwrap import indent
-import tomli_w
+import tomlkit
# Template directory
TEMPLATE_DIR = Path(__file__).parent / "template"
@@ -126,8 +125,7 @@ def find_module_name(project_dir: Path) -> str | None:
if not pyproject.exists():
return None
- with open(pyproject, "rb") as f:
- data = tomllib.load(f)
+ data = tomlkit.parse(pyproject.read_text())
if "project" in data and "name" in data["project"]:
name = data["project"]["name"]
@@ -200,19 +198,15 @@ def patch_app_file(
print(f"❌ Cannot patch {path} - file not found")
return False
- content = path.read_text()
+ original_content = path.read_text()
marker = "from fastapi_vue import Frontend"
- if marker in content:
- print(f"⚠️ Skipping {path} (already patched)")
+ if marker in original_content:
+ print(f"✔️ {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")
+ lines = original_content.split("\n")
import_line = "from fastapi_vue import Frontend"
route_line = f'frontend.route({app_var}, "/")'
@@ -230,6 +224,11 @@ 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")
@@ -268,6 +267,15 @@ def patch_app_file(
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}")
@@ -303,21 +311,17 @@ def patch_vite_config(
print(f"❌ Cannot patch {path} - file not found")
return False
- content = path.read_text()
+ original_content = path.read_text()
marker = "vite-plugin-fastapi"
- if marker in content:
- print(f"⚠️ Skipping {path} (already patched)")
+ if marker in original_content:
+ print(f"✔️ {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")
+ lines = original_content.split("\n")
new_lines = []
import_inserted = False
@@ -349,6 +353,18 @@ 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}")
@@ -384,16 +400,14 @@ 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
- content = target_file.read_text()
+ original_content = target_file.read_text()
# Check if already patched
- if "/api/health" in content:
- print(f"⚠️ Skipping {target_file} (already patched)")
+ if "/api/health" in original_content:
+ print(f"✔️ {target_file} (already patched)")
return False
- if dry_run:
- print(f"[DRY RUN] Would patch {target_file}")
- return True
+ content = original_content
# Detect if TypeScript (has lang="ts" in script tag)
is_typescript = 'lang="ts"' in content
@@ -405,30 +419,52 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
# Insert script addition before
script_end_match = re.search(r"", content)
- if script_end_match:
- insert_pos = script_end_match.start()
- content = content[:insert_pos] + script_addition + content[insert_pos:]
+ if not script_end_match:
+ print(f"⚠️ Skipping {target_file} (no tag found)")
+ return False
+
+ 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
- # For App.vue (minimal): insert before the last
in template
+ # For App.vue (minimal): only patch if it's the default "You did it!" template
if "HelloWorld" in str(target_file):
# Insert before closing
h3_close = content.find(" ")
if h3_close == -1:
+ print(
+ f"⚠️ Skipping {target_file} (no 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 - insert before the last before
+ # Minimal App.vue - only patch if it contains the default welcome message
+ if "You did it!
" not in content:
+ print(f"⚠️ Skipping {target_file} (not a default Vue template)")
+ return False
+ # Insert before the tag
template_end = content.find("")
- if template_end != -1:
- # Find last before
- last_p = content.rfind(" ", 0, template_end)
- if last_p == -1:
- return False
- before, after = content[:last_p], content[last_p:]
- content = f"{before}{STATUS_SPAN_TEMPLATE}{after}"
+ if template_end == -1:
+ print(f"⚠️ Skipping {target_file} (no tag found)")
+ return False
+ # Find before
+ last_p = content.rfind("", 0, template_end)
+ if last_p == -1:
+ print(f"⚠️ Skipping {target_file} (no 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
target_file.write_text(content)
print(f"✅ Patched {target_file}")
@@ -445,9 +481,16 @@ def write_file(
"""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 dry_run:
action = "overwrite" if exists else "create"
print(f"[DRY RUN] Would {action} {path}")
@@ -462,47 +505,72 @@ def write_file(
return True
-def merge_pyproject(data: dict, additions: dict, module_name: str) -> dict:
- """Merge additions into pyproject.toml data."""
- result = data.copy()
-
+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 result:
- result["build-system"] = {}
- result["build-system"]["requires"] = ["hatchling"]
- result["build-system"]["build-backend"] = "hatchling.build"
+ if "build-system" not in data:
+ data["build-system"] = tomlkit.table()
- # Add dependencies
- if "project" not in result:
- result["project"] = {}
- if "dependencies" not in result["project"]:
- result["project"]["dependencies"] = []
+ # 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()
# 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"] = {}
+ 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()
- 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"]
+ hatch_build = data["tool"]["hatch"]["build"]
+ hatch_additions = additions["tool"]["hatch"]["build"]
- if "targets" not in result["tool"]["hatch"]["build"]:
- result["tool"]["hatch"]["build"]["targets"] = {}
+ # 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"]
+ ]
- result["tool"]["hatch"]["build"]["targets"]["sdist"] = hatch_build["targets"][
- "sdist"
- ]
+ # 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"]
+ ]
- return result
+ # 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
# =============================================================================
@@ -742,10 +810,28 @@ def cmd_setup(args: argparse.Namespace) -> int:
# === Handle __main__.py ===
main_file = module_dir / "__main__.py"
+ alt_main_file = module_dir / "__main__.fastapi-vue.py"
+ template = load_template("backend/__main__.py")
+ main_content = render_template(template, **tpl_vars)
+ used_alt_main = False
+
if not main_file.exists():
- template = load_template("backend/__main__.py")
- content = render_template(template, **tpl_vars)
- write_file(main_file, content, overwrite=False, dry_run=dry_run)
+ # No __main__.py, create it
+ write_file(main_file, main_content, overwrite=False, dry_run=dry_run)
+ else:
+ # Check if existing __main__.py is ours or compatible
+ existing_content = main_file.read_text()
+ if "fastapi_vue" in existing_content or existing_content == main_content:
+ # Already ours or matches - skip or update
+ if existing_content != main_content:
+ write_file(main_file, main_content, overwrite=True, dry_run=dry_run)
+ else:
+ print(f"✔️ {main_file} (already up to date)")
+ else:
+ # User has their own __main__.py, install as alternate filename
+ print(f"⚠️ {main_file} exists and is not ours, using alternate name")
+ write_file(alt_main_file, main_content, overwrite=True, dry_run=dry_run)
+ used_alt_main = True
# === Update vite.config.js/ts ===
frontend_dir = project_dir / "frontend"
@@ -774,39 +860,52 @@ def cmd_setup(args: argparse.Namespace) -> int:
# === Update pyproject.toml ===
pyproject_path = project_dir / "pyproject.toml"
if pyproject_path.exists():
- with open(pyproject_path, "rb") as f:
- data = tomllib.load(f)
+ old_content = pyproject_path.read_text()
+ data = tomlkit.parse(old_content)
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"
+ # 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("_", "-")
+ if used_alt_main:
+ # Use the alternate module name with dot notation
+ main_module = "__main__.fastapi-vue"
+ updated["project"]["scripts"][script_name] = (
+ f"{module_name}.{main_module}:main"
+ )
+ else:
+ updated["project"]["scripts"][script_name] = (
+ f"{module_name}.__main__:main"
+ )
- if dry_run:
+ # 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:
print(f"[DRY RUN] Would update {pyproject_path}")
else:
- with open(pyproject_path, "wb") as f:
- tomli_w.dump(updated, f)
+ pyproject_path.write_text(new_content)
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")
+ print("[DRY RUN] Would run: uv add -U 'fastapi[standard]' fastapi-vue")
+ print("[DRY RUN] Would run: uv add -U --group dev httpx")
else:
print("📦 Adding dependencies...")
result = subprocess.run(
- ["uv", "add", "fastapi[standard]", "fastapi-vue"],
+ ["uv", "add", "-U", "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"],
+ ["uv", "add", "-U", "--group", "dev", "httpx"],
cwd=project_dir,
check=False,
)
diff --git a/pyproject.toml b/pyproject.toml
index c3e3d06..c42c750 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,7 +9,7 @@ description = "Tool to create or patch FastAPI+Vue projects with integrated buil
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
- "tomli-w>=1.0.0",
+ "tomlkit>=0.12.0",
]
[project.urls]
diff --git a/template/scripts/devserver.py b/template/scripts/devserver.py
index 80a7ac0..f2ed0c3 100755
--- a/template/scripts/devserver.py
+++ b/template/scripts/devserver.py
@@ -16,17 +16,15 @@ 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"
@@ -45,45 +43,8 @@ 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_host: str | None, vite_port: int, all_ifaces: bool
+ vite_port: int, all_ifaces: bool
) -> tuple[list[str], list[str], str]:
"""Resolve frontend install and dev commands.
@@ -123,8 +84,6 @@ 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)
@@ -168,21 +127,18 @@ 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,
+ backend_ep: dict,
) -> 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
- )
+ install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port, all_ifaces)
+
+ backend_host = backend_ep["host"]
+ backend_port = backend_ep["port"]
# Tell the backend where the Vite dev server is
- os.environ["FASTAPI_VUE_FRONTEND_URL"] = (
- f"http://{vite_host or 'localhost'}:{vite_port}"
- )
+ os.environ["FASTAPI_VUE_FRONTEND_URL"] = f"http://localhost:{vite_port}"
# Tell Vite where the backend is (for proxying /api requests)
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
cwd = str(Path(__file__).parent.parent)
@@ -292,15 +248,21 @@ def main():
)
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
+ # 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("┃ ⚠️ Vite does not support unix sockets\n")
+ raise SystemExit(1)
+
+ vite_port = vite_endpoints[0]["port"]
+ all_ifaces = len(vite_endpoints) > 1
with contextlib.suppress(KeyboardInterrupt):
- asyncio.run(
- run_devserver(vite_host, vite_port, all_ifaces, backend_host, backend_port)
- )
+ asyncio.run(run_devserver(vite_port, all_ifaces, backend_endpoints[0]))
if __name__ == "__main__":