Compare commits

..
14 Commits
Author SHA1 Message Date
LeoVasanko 4156eb2db3 Better handling of upgrades, an option to avoid overwriting main and devserver scripts, writing .new.py instead. Error out on unix socket in devserver. 2026-01-21 21:13:23 +00:00
LeoVasanko 3f6559b2db Simplify devserver script and remove its duplicated parse_endpoint function. 2026-01-18 01:15:34 +00:00
LeoVasanko 71d4e053a7 Strictly correct indent and formatting on Vue demo patches. 2026-01-17 23:21:01 +00:00
LeoVasanko 2dec331afb Setup script cleanup. Use discovered app module and var names when creating __main__.py from template. README updates. 2026-01-17 23:04:07 +00:00
LeoVasanko 64f43df3e3 Completely rewritten CLI main, support unix sockets and wildcard bindings. 2026-01-17 22:48:13 +00:00
LeoVasanko 6ccba2be9f Adjust vite args (cleanup, don't clear screen). 2026-01-17 22:20:27 +00:00
LeoVasanko 2b735528ab Fix another Deno issue running Vue without typescript. 2026-01-17 22:19:28 +00:00
LeoVasanko e6170549f0 README 2026-01-17 20:21:31 +00:00
LeoVasanko a2983a62a0 Simplified templating, backend port config on devserver.py, simplified Vue app patches. 2026-01-17 19:51:47 +00:00
LeoVasanko d9323f34e1 Silence type warnings for symbols that actually are available at build time. 2026-01-17 17:08:50 +00:00
LeoVasanko a4379094db README 2026-01-17 16:42:02 +00:00
LeoVasanko 52ec232ce4 Fix deno npm run-p problem that was preventing TypeScript builds on Deno, by using build-only instead. 2026-01-17 16:40:43 +00:00
LeoVasanko a6ef9b7c8f Better handling of dependencies. Display FastAPI status in Vue demo apps. 2026-01-17 03:28:41 +00:00
LeoVasanko 44e06df66c Initial commit 2026-01-17 01:53:02 +00:00
5 changed files with 295 additions and 178 deletions
-1
View File
@@ -3,4 +3,3 @@
*.lock *.lock
__pycache__/ __pycache__/
dist/ dist/
/_version.py
+246 -96
View File
@@ -15,20 +15,18 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import tomllib
from pathlib import Path from pathlib import Path
from textwrap import indent from textwrap import indent
import tomli_w import tomlkit
try:
from _version import __version__
except ImportError:
__version__ = "0.0.0+unknown"
# Template directory # Template directory
TEMPLATE_DIR = Path(__file__).parent / "template" 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.toml additions for patched projects
PYPROJECT_ADDITIONS = { PYPROJECT_ADDITIONS = {
"tool": { "tool": {
@@ -131,8 +129,7 @@ def find_module_name(project_dir: Path) -> str | None:
if not pyproject.exists(): if not pyproject.exists():
return None return None
with open(pyproject, "rb") as f: data = tomlkit.parse(pyproject.read_text())
data = tomllib.load(f)
if "project" in data and "name" in data["project"]: if "project" in data and "name" in data["project"]:
name = data["project"]["name"] name = data["project"]["name"]
@@ -205,19 +202,15 @@ def patch_app_file(
print(f"❌ Cannot patch {path} - file not found") print(f"❌ Cannot patch {path} - file not found")
return False return False
content = path.read_text() original_content = path.read_text()
marker = "from fastapi_vue import Frontend" marker = "from fastapi_vue import Frontend"
if marker in content: if marker in original_content:
print(f" Skipping {path} (already patched)") print(f" {path} (already patched)")
return False return False
if dry_run:
print(f"[DRY RUN] Would patch {path}")
return True
# Find where to insert the import (after other imports) # 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" import_line = "from fastapi_vue import Frontend"
route_line = f'frontend.route({app_var}, "/")' route_line = f'frontend.route({app_var}, "/")'
@@ -235,6 +228,11 @@ def patch_app_file(
# Stop at first non-import, non-comment, non-empty line after imports # Stop at first non-import, non-comment, non-empty line after imports
break 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 # Insert imports after last import, then frontend instantiation
if not has_pathlib: if not has_pathlib:
lines.insert(last_import_idx + 1, "from pathlib import Path") lines.insert(last_import_idx + 1, "from pathlib import Path")
@@ -273,6 +271,15 @@ def patch_app_file(
content = content[:insert_pos] + load_code + content[insert_pos:] content = content[:insert_pos] + load_code + content[insert_pos:]
lifespan_patched = True 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) path.write_text(content)
print(f"✅ Patched {path}") print(f"✅ Patched {path}")
@@ -308,21 +315,17 @@ def patch_vite_config(
print(f"❌ Cannot patch {path} - file not found") print(f"❌ Cannot patch {path} - file not found")
return False return False
content = path.read_text() original_content = path.read_text()
marker = "vite-plugin-fastapi" marker = "vite-plugin-fastapi"
if marker in content: if marker in original_content:
print(f" Skipping {path} (already patched)") print(f" {path} (already patched)")
return False 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) # Add import for the plugin at the top (after other imports)
import_line = f"import fastapiVue from './{marker}.js'" import_line = f"import fastapiVue from './{marker}.js'"
lines = content.split("\n") lines = original_content.split("\n")
new_lines = [] new_lines = []
import_inserted = False import_inserted = False
@@ -354,6 +357,18 @@ def patch_vite_config(
if match: if match:
insert_pos = match.end() insert_pos = match.end()
content = content[:insert_pos] + "\n fastapiVue()," + content[insert_pos:] 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) path.write_text(content)
print(f"✅ Patched {path}") print(f"✅ Patched {path}")
@@ -389,16 +404,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") print("⚠️ No Vue file found to patch, skipping frontend health check")
return False return False
content = target_file.read_text() original_content = target_file.read_text()
# Check if already patched # Check if already patched
if "/api/health" in content: if "/api/health" in original_content:
print(f" Skipping {target_file} (already patched)") print(f" {target_file} (already patched)")
return False return False
if dry_run: content = original_content
print(f"[DRY RUN] Would patch {target_file}")
return True
# Detect if TypeScript (has lang="ts" in script tag) # Detect if TypeScript (has lang="ts" in script tag)
is_typescript = 'lang="ts"' in content is_typescript = 'lang="ts"' in content
@@ -410,47 +423,95 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
# Insert script addition before </script> # Insert script addition before </script>
script_end_match = re.search(r"</script>", content) script_end_match = re.search(r"</script>", content)
if script_end_match: if not script_end_match:
print(f"⚠️ Skipping {target_file} (no </script> tag found)")
return False
insert_pos = script_end_match.start() insert_pos = script_end_match.start()
content = content[:insert_pos] + script_addition + content[insert_pos:] content = content[:insert_pos] + script_addition + content[insert_pos:]
# Insert status inline - find the best place based on file type # Insert status inline - find the best place based on file type
# For HelloWorld.vue: insert before </h3> # For HelloWorld.vue: insert before </h3>
# For App.vue (minimal): insert before the last </p> in template # For App.vue (minimal): only patch if it's the default "You did it!" template
if "HelloWorld" in str(target_file): if "HelloWorld" in str(target_file):
# Insert before closing </h3> # Insert before closing </h3>
h3_close = content.find(" </h3>") h3_close = content.find(" </h3>")
if h3_close == -1: if h3_close == -1:
print(
f"⚠️ Skipping {target_file} (no </h3> tag found for status insertion)"
)
return False return False
before, after = content[:h3_close], content[h3_close:] before, after = content[:h3_close], content[h3_close:]
content = f"{before}{indent(STATUS_SPAN_TEMPLATE, ' ')}{after}" content = f"{before}{indent(STATUS_SPAN_TEMPLATE, ' ')}{after}"
else: else:
# Minimal App.vue - insert before the last </p> before </template> # 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
template_end = content.find("</template>") template_end = content.find("</template>")
if template_end != -1: if template_end == -1:
# Find last </p> before </template> print(f"⚠️ Skipping {target_file} (no </template> tag found)")
last_p = content.rfind(" </p>", 0, template_end) return False
# Find </p> before </template>
last_p = content.rfind("</p>", 0, template_end)
if last_p == -1: if last_p == -1:
print(f"⚠️ Skipping {target_file} (no </p> tag found for status insertion)")
return False return False
before, after = content[:last_p], content[last_p:] before, after = content[:last_p], content[last_p:]
content = f"{before}{STATUS_SPAN_TEMPLATE}{after}" 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) target_file.write_text(content)
print(f"✅ Patched {target_file}") print(f"✅ Patched {target_file}")
return True return True
# Track .new.py files written during setup (for merge notification)
_new_files_written: list[tuple[Path, Path]] = []
def write_file( def write_file(
path: Path, path: Path,
content: str, content: str,
overwrite: bool = True, overwrite: bool = True,
dry_run: bool = False, dry_run: bool = False,
executable: bool = False, executable: bool = False,
fallback_path: Path | None = None,
) -> bool: ) -> bool:
"""Write content to a file, handling existing files and dry-run.""" """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.
"""
exists = path.exists() exists = path.exists()
if exists and not overwrite: 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 return False
if dry_run: if dry_run:
@@ -467,47 +528,100 @@ def write_file(
return True return True
def merge_pyproject(data: dict, additions: dict, module_name: str) -> dict: def _write_fallback_file(
"""Merge additions into pyproject.toml data.""" original_path: Path,
result = data.copy() 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
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 # Ensure hatchling build system is configured
if "build-system" not in result: if "build-system" not in data:
result["build-system"] = {} data["build-system"] = tomlkit.table()
result["build-system"]["requires"] = ["hatchling"]
result["build-system"]["build-backend"] = "hatchling.build"
# Add dependencies # Add hatchling to requires if not present, preserving existing requires
if "project" not in result: if "requires" not in data["build-system"]:
result["project"] = {} data["build-system"]["requires"] = ["hatchling"]
if "dependencies" not in result["project"]: else:
result["project"]["dependencies"] = [] 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 # Add hatch build config
if "tool" not in result: if "tool" not in data:
result["tool"] = {} data["tool"] = tomlkit.table()
if "hatch" not in result["tool"]: if "hatch" not in data["tool"]:
result["tool"]["hatch"] = {} data["tool"]["hatch"] = tomlkit.table()
if "build" not in result["tool"]["hatch"]: if "build" not in data["tool"]["hatch"]:
result["tool"]["hatch"]["build"] = {} data["tool"]["hatch"]["build"] = tomlkit.table()
hatch_build = additions["tool"]["hatch"]["build"] hatch_build = data["tool"]["hatch"]["build"]
result["tool"]["hatch"]["build"]["packages"] = [ hatch_additions = additions["tool"]["hatch"]["build"]
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"]
if "targets" not in result["tool"]["hatch"]["build"]: # Set packages if not already set
result["tool"]["hatch"]["build"]["targets"] = {} if "packages" not in hatch_build:
hatch_build["packages"] = [
result["tool"]["hatch"]["build"]["targets"]["sdist"] = hatch_build["targets"][ p.replace("MODULE_NAME", module_name) for p in hatch_additions["packages"]
"sdist"
] ]
return result # 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"]
]
# 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
# ============================================================================= # =============================================================================
@@ -705,25 +819,38 @@ def cmd_setup(args: argparse.Namespace) -> int:
module_dir.mkdir(parents=True) module_dir.mkdir(parents=True)
# === Install scripts (always update our own scripts) === # === Install scripts (always update our own scripts) ===
script_files = [ # util.py and build-frontend.py are internal and always overwritten
# devserver.py can be customized, so use fallback if needed
internal_script_files = [
(fastapi_vue_scripts / "util.py", "scripts/fastapi-vue/util.py"), (fastapi_vue_scripts / "util.py", "scripts/fastapi-vue/util.py"),
( (
fastapi_vue_scripts / "build-frontend.py", fastapi_vue_scripts / "build-frontend.py",
"scripts/fastapi-vue/build-frontend.py", "scripts/fastapi-vue/build-frontend.py",
), ),
(scripts_dir / "devserver.py", "scripts/devserver.py"),
] ]
for dest_path, template_path in script_files: for dest_path, template_path in internal_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( write_file(
dest_path, dest_path,
content, content,
overwrite=True, overwrite=True,
dry_run=dry_run, 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 === # === Handle app module ===
@@ -747,10 +874,21 @@ 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(): main_fallback = module_dir / "__main__.new.py"
template = load_template("backend/__main__.py") template = load_template("backend/__main__.py")
content = render_template(template, **tpl_vars) main_content = render_template(template, **tpl_vars)
write_file(main_file, content, overwrite=False, dry_run=dry_run)
# 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,
)
# === Update vite.config.js/ts === # === Update vite.config.js/ts ===
frontend_dir = project_dir / "frontend" frontend_dir = project_dir / "frontend"
@@ -779,39 +917,43 @@ def cmd_setup(args: argparse.Namespace) -> int:
# === 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():
with open(pyproject_path, "rb") as f: old_content = pyproject_path.read_text()
data = tomllib.load(f) data = tomlkit.parse(old_content)
updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name) updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name)
# Add script entry pointing to the app we found/created # Only add script entry if project doesn't already have scripts
if "scripts" not in updated["project"]: if "scripts" not in updated["project"] or not updated["project"]["scripts"]:
updated["project"]["scripts"] = {} updated["project"]["scripts"] = tomlkit.table()
script_name = module_name.replace("_", "-") script_name = module_name.replace("_", "-")
updated["project"]["scripts"][script_name] = f"{module_name}.__main__:main" 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}") print(f"[DRY RUN] Would update {pyproject_path}")
else: else:
with open(pyproject_path, "wb") as f: pyproject_path.write_text(new_content)
tomli_w.dump(updated, f)
print(f"✅ Updated {pyproject_path}") print(f"✅ Updated {pyproject_path}")
# === Add dependencies using uv === # === Add dependencies using uv ===
if dry_run: if dry_run:
print("[DRY RUN] Would run: uv add 'fastapi[standard]' fastapi-vue") print("[DRY RUN] Would run: uv add -U 'fastapi[standard]' fastapi-vue")
print("[DRY RUN] Would run: uv add --group dev httpx") print("[DRY RUN] Would run: uv add -U --group dev httpx")
else: else:
print("📦 Adding dependencies...") print("📦 Adding dependencies...")
result = subprocess.run( result = subprocess.run(
["uv", "add", "fastapi[standard]", "fastapi-vue"], ["uv", "add", "-U", "fastapi[standard]", "fastapi-vue"],
cwd=project_dir, cwd=project_dir,
check=False, check=False,
) )
if result.returncode != 0: if result.returncode != 0:
print("⚠️ Failed to add main dependencies") print("⚠️ Failed to add main dependencies")
result = subprocess.run( result = subprocess.run(
["uv", "add", "--group", "dev", "httpx"], ["uv", "add", "-U", "--group", "dev", "httpx"],
cwd=project_dir, cwd=project_dir,
check=False, check=False,
) )
@@ -852,6 +994,19 @@ def cmd_setup(args: argparse.Namespace) -> int:
) )
print(message) 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 return 0
@@ -901,11 +1056,6 @@ Examples:
parser.add_argument( parser.add_argument(
"--dry-run", action="store_true", help="Show what would be done" "--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() args = parser.parse_args()
+1 -4
View File
@@ -9,7 +9,7 @@ description = "Tool to create or patch FastAPI+Vue projects with integrated buil
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"tomli-w>=1.0.0", "tomlkit>=0.12.0",
] ]
[project.urls] [project.urls]
@@ -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"]
+1
View File
@@ -1,3 +1,4 @@
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
import argparse import argparse
import asyncio import asyncio
import os import os
+38 -68
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env -S uv run #!/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. """Run Vite development server for frontend and FastAPI backend with auto-reload.
Usage: Usage:
@@ -16,17 +17,15 @@ Environment:
import argparse import argparse
import asyncio import asyncio
import contextlib import contextlib
import ipaddress
import os import os
from pathlib import Path from pathlib import Path
from sys import stderr from sys import stderr
from urllib.parse import urlparse
import httpx import httpx
from fastapi_vue.hostutil import parse_endpoint
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_VITE_PORT = 5173 DEFAULT_VITE_PORT = 5173
DEFAULT_BACKEND_PORT = 5180 DEFAULT_BACKEND_PORT = 5180
FRONTEND_PATH = Path(__file__).parent.parent / "frontend" FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
@@ -45,45 +44,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( 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]: ) -> tuple[list[str], list[str], str]:
"""Resolve frontend install and dev commands. """Resolve frontend install and dev commands.
@@ -123,8 +85,6 @@ def resolve_frontend_tools(
if all_ifaces: if all_ifaces:
dev_cmd.append("--host") dev_cmd.append("--host")
elif vite_host and vite_host != "localhost":
dev_cmd.append(f"--host={vite_host}")
if name == "bun": if name == "bun":
stderr.write(BUN_BUG) stderr.write(BUN_BUG)
@@ -132,13 +92,15 @@ 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(host: str, port: int):
"""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
url = f"http://{host}:{port}"
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(url, timeout=1.0)
stderr.write("✓ Backend ready!\n") stderr.write("✓ Backend ready!\n")
return True return True
except httpx.RequestError: except httpx.RequestError:
@@ -168,23 +130,29 @@ async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> Non
async def run_devserver( async def run_devserver(
vite_host: str | None,
vite_port: int, vite_port: int,
all_ifaces: bool, all_ifaces: bool,
backend_host: str, backend_host: str,
backend_port: int, 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(vite_port, all_ifaces)
vite_host, vite_port, all_ifaces
)
# Tell the backend where the Vite dev server is # Tell the backend where the Vite dev server is
os.environ["FASTAPI_VUE_FRONTEND_URL"] = ( os.environ["FASTAPI_VUE_FRONTEND_URL"] = f"http://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["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) cwd = str(Path(__file__).parent.parent)
frontend_cwd = str(FRONTEND_PATH) frontend_cwd = str(FRONTEND_PATH)
@@ -202,15 +170,6 @@ async def run_devserver(
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# Start backend (concurrent with install) # 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") stderr.write(f">>> {' '.join(backend_cmd)}\n")
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd) backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
@@ -292,15 +251,26 @@ def main():
) )
args = parser.parse_args() args = parser.parse_args()
vite_host, vite_port, all_ifaces = parse_endpoint(args.frontend, DEFAULT_VITE_PORT) # parse_endpoint returns list of dicts with host/port or uds keys
backend_host, backend_port, _ = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT) # Multiple entries means bind all interfaces (IPv4 + IPv6)
# Backend host defaults to localhost (never None) vite_endpoints = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
backend_host = backend_host or DEFAULT_HOST 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"]
with contextlib.suppress(KeyboardInterrupt): with contextlib.suppress(KeyboardInterrupt):
asyncio.run( asyncio.run(run_devserver(vite_port, all_ifaces, backend_host, backend_port))
run_devserver(vite_host, vite_port, all_ifaces, backend_host, backend_port)
)
if __name__ == "__main__": if __name__ == "__main__":