Compare commits

..
13 Commits
8 changed files with 564 additions and 549 deletions
+1
View File
@@ -3,3 +3,4 @@
*.lock *.lock
__pycache__/ __pycache__/
dist/ dist/
/_version.py
+139 -463
View File
@@ -13,21 +13,22 @@ Options:
import argparse import argparse
import os import os
import re import re
import shutil
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 tomlkit import tomli_w
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": {
@@ -52,7 +53,9 @@ PYPROJECT_ADDITIONS = {
# Frontend instantiation block for patching existing apps # Frontend instantiation block for patching existing apps
FRONTEND_BLOCK = """ FRONTEND_BLOCK = """
# Vue Frontend static files # 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 # TypeScript health check script for Vue components
@@ -128,7 +131,8 @@ def find_module_name(project_dir: Path) -> str | None:
if not pyproject.exists(): if not pyproject.exists():
return None 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"]: if "project" in data and "name" in data["project"]:
name = data["project"]["name"] name = data["project"]["name"]
@@ -137,18 +141,10 @@ def find_module_name(project_dir: Path) -> str | None:
return None return None
def find_fastapi_app( def find_fastapi_app(module_dir: Path) -> tuple[Path, str] | None:
module_dir: Path, project_dir: Path | None = None
) -> tuple[Path, str] | None:
"""Find the FastAPI app in a module directory. """Find the FastAPI app in a module directory.
Returns (file_path, app_variable_name) or None if not found. 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 # Common app file names to check first
candidates = ["app.py", "main.py", "server.py", "api.py", "__init__.py"] candidates = ["app.py", "main.py", "server.py", "api.py", "__init__.py"]
@@ -161,155 +157,13 @@ def find_fastapi_app(
if result: if result:
return path, result return path, result
# Then check all .py files in module_dir # Then check all .py files
for path in module_dir.glob("*.py"): for path in module_dir.glob("*.py"):
if path.name not in candidates: if path.name not in candidates:
result = _find_app_in_file(path) result = _find_app_in_file(path)
if result: if result:
return path, 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 return None
@@ -351,15 +205,19 @@ def patch_app_file(
print(f"❌ Cannot patch {path} - file not found") print(f"❌ Cannot patch {path} - file not found")
return False return False
original_content = path.read_text() content = path.read_text()
marker = "from fastapi_vue import Frontend" marker = "from fastapi_vue import Frontend"
if marker in original_content: if marker in content:
print(f" {path} (already patched)") print(f" Skipping {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 = original_content.split("\n") lines = 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}, "/")'
@@ -377,11 +235,6 @@ 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")
@@ -391,33 +244,35 @@ def patch_app_file(
# Append route at end # Append route at end
lines.append("") lines.append("")
lines.append("# Serve the Vue frontend (needs to be last if SPA catch-all is used)")
lines.append(route_line) lines.append(route_line)
content = "\n".join(lines) content = "\n".join(lines)
# Try to patch lifespan function - insert await frontend.load() before yield # Try to patch lifespan function
lifespan_patched = False lifespan_patched = False
# Look for yield inside an async def lifespan function # Look for async def lifespan pattern and insert after the opening (and docstring if present)
# Find the yield statement and insert before it lifespan_pattern = r"(async\s+def\s+lifespan\s*\([^)]*\)\s*(?:->.*?)?:\s*\n)"
yield_pattern = r"^([ \t]+)(yield\b)" match = re.search(lifespan_pattern, content)
yield_match = re.search(yield_pattern, content, re.MULTILINE) if match:
if yield_match: insert_pos = match.end()
indent = yield_match.group(1) rest = content[insert_pos:]
insert_pos = yield_match.start()
# 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" load_code = f"{indent}await frontend.load()\n"
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}")
@@ -453,17 +308,21 @@ def patch_vite_config(
print(f"❌ Cannot patch {path} - file not found") print(f"❌ Cannot patch {path} - file not found")
return False return False
original_content = path.read_text() content = path.read_text()
marker = "vite-plugin-fastapi" marker = "vite-plugin-fastapi"
if marker in original_content: if marker in content:
print(f" {path} (already patched)") print(f" Skipping {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 = original_content.split("\n") lines = content.split("\n")
new_lines = [] new_lines = []
import_inserted = False import_inserted = False
@@ -495,18 +354,6 @@ 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}")
@@ -542,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") print("⚠️ No Vue file found to patch, skipping frontend health check")
return False return False
original_content = target_file.read_text() content = target_file.read_text()
# Check if already patched # Check if already patched
if "/api/health" in original_content: if "/api/health" in content:
print(f" {target_file} (already patched)") print(f" Skipping {target_file} (already patched)")
return False 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) # Detect if TypeScript (has lang="ts" in script tag)
is_typescript = 'lang="ts"' in content is_typescript = 'lang="ts"' in content
@@ -561,98 +410,47 @@ 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 not script_end_match: if 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): 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): 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 - only patch if it contains the default welcome message # Minimal App.vue - insert before the last </p> before </template>
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:
print(f"⚠️ Skipping {target_file} (no </template> tag found)") # Find last </p> before </template>
return False
# Find </p> before </template>
last_p = content.rfind(" </p>", 0, template_end) 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,
force: 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."""
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.
If force=True, always overwrite without checking for upgrade marker.
"""
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 (unless force)
if overwrite and not force 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:
@@ -669,114 +467,47 @@ def write_file(
return True return True
def _write_fallback_file( def merge_pyproject(data: dict, additions: dict, module_name: str) -> dict:
original_path: Path, """Merge additions into pyproject.toml data."""
fallback_path: Path, result = data.copy()
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 data: if "build-system" not in result:
data["build-system"] = tomlkit.table() 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 # Add dependencies
if "requires" not in data["build-system"]: if "project" not in result:
data["build-system"]["requires"] = ["hatchling"] result["project"] = {}
else: if "dependencies" not in result["project"]:
requires = list(data["build-system"]["requires"]) result["project"]["dependencies"] = []
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 hatch build config # Add hatch build config
if "tool" not in data: if "tool" not in result:
data["tool"] = tomlkit.table() result["tool"] = {}
if "hatch" not in data["tool"]: if "hatch" not in result["tool"]:
data["tool"]["hatch"] = tomlkit.table() result["tool"]["hatch"] = {}
if "build" not in data["tool"]["hatch"]: if "build" not in result["tool"]["hatch"]:
data["tool"]["hatch"]["build"] = tomlkit.table() result["tool"]["hatch"]["build"] = {}
hatch_build = data["tool"]["hatch"]["build"] hatch_build = additions["tool"]["hatch"]["build"]
hatch_additions = 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 "targets" not in result["tool"]["hatch"]["build"]:
if "packages" not in hatch_build: result["tool"]["hatch"]["build"]["targets"] = {}
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 return result
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
# ============================================================================= # =============================================================================
@@ -790,6 +521,8 @@ def find_js_runtime() -> tuple[str, str] | None:
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun". Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Returns None if no runtime is found. Returns None if no runtime is found.
""" """
import shutil
options = ["deno", "npm", "bun"] options = ["deno", "npm", "bun"]
# Check for JS_RUNTIME environment variable # Check for JS_RUNTIME environment variable
@@ -951,9 +684,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
fastapi_vue_scripts = scripts_dir / "fastapi-vue" fastapi_vue_scripts = scripts_dir / "fastapi-vue"
# Find existing FastAPI app # Find existing FastAPI app
app_info = ( app_info = find_fastapi_app(module_dir) if module_dir.exists() else None
find_fastapi_app(module_dir, project_dir) if module_dir.exists() else None
)
if app_info: if app_info:
app_file, app_var = app_info app_file, app_var = app_info
@@ -969,50 +700,30 @@ def cmd_setup(args: argparse.Namespace) -> int:
# Create directories # Create directories
if not dry_run: if not dry_run:
fastapi_vue_scripts.mkdir(parents=True, exist_ok=True)
if not module_dir.exists(): if not module_dir.exists():
module_dir.mkdir(parents=True) module_dir.mkdir(parents=True)
fastapi_vue_scripts.mkdir(parents=True, exist_ok=True)
# === Install scripts (always update our own scripts) === # === Install scripts (always update our own scripts) ===
# Scripts in fastapi-vue folder are internal and always overwritten script_files = [
# devserver.py can be customized, so use fallback if needed (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"),
]
# Remove obsolete util.py if present for dest_path, template_path in script_files:
obsolete_util = fastapi_vue_scripts / "util.py" template = load_template(template_path)
if obsolete_util.exists():
if dry_run:
print(f"[DRY RUN] Would remove obsolete {obsolete_util}")
else:
obsolete_util.unlink()
print(f"🗑️ Removed obsolete {obsolete_util}")
# Copy all files from the template's fastapi-vue folder
template_fastapi_vue_dir = TEMPLATE_DIR / "scripts" / "fastapi-vue"
for template_file in template_fastapi_vue_dir.iterdir():
if template_file.is_file():
dest_path = fastapi_vue_scripts / template_file.name
template = template_file.read_text()
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,
force=True, # Internal files, always overwrite 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 ===
@@ -1036,31 +747,10 @@ 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"
main_fallback = module_dir / "__main__.new.py" if not main_file.exists():
template = load_template("backend/__main__.py") template = load_template("backend/__main__.py")
main_content = render_template(template, **tpl_vars) content = render_template(template, **tpl_vars)
write_file(main_file, content, overwrite=False, dry_run=dry_run)
# Check if project already has a CLI entrypoint in pyproject.toml
existing_cli = _find_existing_cli_entrypoint(project_dir, module_name)
if main_file.exists():
# File exists: update if it has the auto-upgrade marker, otherwise use fallback
write_file(
main_file,
main_content,
overwrite=True,
dry_run=dry_run,
fallback_path=main_fallback,
)
elif not existing_cli:
# No file and no existing entrypoint: create new __main__.py
write_file(
main_file,
main_content,
overwrite=False,
dry_run=dry_run,
)
# else: no file but has existing entrypoint - don't create (user has custom CLI setup)
# === Update vite.config.js/ts === # === Update vite.config.js/ts ===
frontend_dir = project_dir / "frontend" frontend_dir = project_dir / "frontend"
@@ -1089,43 +779,39 @@ 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():
old_content = pyproject_path.read_text() with open(pyproject_path, "rb") as f:
data = tomlkit.parse(old_content) data = tomllib.load(f)
updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name) updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name)
# Only add script entry if project doesn't already have scripts # Add script entry pointing to the app we found/created
if "scripts" not in updated["project"] or not updated["project"]["scripts"]: if "scripts" not in updated["project"]:
updated["project"]["scripts"] = tomlkit.table() updated["project"]["scripts"] = {}
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"
# Check if content actually changed if dry_run:
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:
pyproject_path.write_text(new_content) with open(pyproject_path, "wb") as f:
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 -U 'fastapi[standard]' fastapi-vue") print("[DRY RUN] Would run: uv add 'fastapi[standard]' fastapi-vue")
print("[DRY RUN] Would run: uv add -U --group dev httpx") print("[DRY RUN] Would run: uv add --group dev httpx")
else: else:
print("📦 Adding dependencies...") print("📦 Adding dependencies...")
result = subprocess.run( result = subprocess.run(
["uv", "add", "-U", "fastapi[standard]", "fastapi-vue"], ["uv", "add", "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", "-U", "--group", "dev", "httpx"], ["uv", "add", "--group", "dev", "httpx"],
cwd=project_dir, cwd=project_dir,
check=False, check=False,
) )
@@ -1134,19 +820,17 @@ def cmd_setup(args: argparse.Namespace) -> int:
# === 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/"
if gitignore_path.exists(): if gitignore_path.exists():
gitignore_content = gitignore_path.read_bytes() gitignore_content = gitignore_path.read_text()
if b"frontend-build" in gitignore_content: if gitignore_entry not in gitignore_content:
print("✔️ .gitignore (frontend-build already ignored)") if dry_run:
elif dry_run:
print(f"[DRY RUN] Would add {gitignore_entry} to .gitignore") print(f"[DRY RUN] Would add {gitignore_entry} to .gitignore")
else: else:
nl = b"\r\n" if b"\r\n" in gitignore_content else b"\n" with open(gitignore_path, "a") as f:
suffix = b"" if gitignore_content.endswith(nl) else nl if not gitignore_content.endswith("\n"):
gitignore_path.write_bytes( f.write("\n")
gitignore_content + suffix + gitignore_entry.encode() + nl f.write(f"{gitignore_entry}\n")
)
print(f"✅ Added {gitignore_entry} to .gitignore") print(f"✅ Added {gitignore_entry} to .gitignore")
elif dry_run: elif dry_run:
print(f"[DRY RUN] Would create .gitignore with {gitignore_entry}") print(f"[DRY RUN] Would create .gitignore with {gitignore_entry}")
@@ -1168,19 +852,6 @@ 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
@@ -1230,6 +901,11 @@ 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()
+4 -1
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 = [
"tomlkit>=0.12.0", "tomli-w>=1.0.0",
] ]
[project.urls] [project.urls]
@@ -22,6 +22,9 @@ 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"]
+15 -6
View File
@@ -1,26 +1,36 @@
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
import argparse import argparse
import asyncio import asyncio
import os import os
import uvicorn
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config, Server from uvicorn import Config, Server
from .APP_MODULE import APP_VAR
DEFAULT_PORT = 5080 DEFAULT_PORT = 5080
def run_server(endpoints: list[dict], *, proxy=""): def run_server(endpoints: list[dict], *, proxy="", devmode=False):
conf: dict[str, object] = {"app": "MODULE_NAME.APP_MODULE:APP_VAR"} conf: dict[str, object] = {"app": "MODULE_NAME.APP_MODULE:APP_VAR"}
if proxy: if proxy:
conf["proxy_headers"] = True conf["proxy_headers"] = True
conf["forwarded_allow_ips"] = proxy 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 def serve_all():
async with asyncio.TaskGroup() as tg: async with asyncio.TaskGroup() as tg:
for ep in endpoints: for ep in endpoints:
tg.create_task(Server(Config(**conf, **ep)).serve()) tg.create_task(Server(Config(**conf, **ep)).serve())
asyncio.run(serve_all()) asyncio.run(serve_all())
else:
uvicorn.run(**conf, **endpoints[0])
def main(): def main():
@@ -35,10 +45,9 @@ def main():
) )
args = parser.parse_args() args = parser.parse_args()
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1") proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
try: devmode = bool(os.getenv("FASTAPI_VUE_FRONTEND_URL"))
run_server(parse_endpoint(args.endpoint, DEFAULT_PORT), proxy=proxy) endpoints = parse_endpoint(args.endpoint, DEFAULT_PORT)
except KeyboardInterrupt: run_server(endpoints, proxy=proxy, devmode=devmode)
pass
if __name__ == "__main__": if __name__ == "__main__":
+4 -2
View File
@@ -5,7 +5,9 @@ from fastapi import FastAPI
from fastapi_vue import Frontend from fastapi_vue import Frontend
# Vue Frontend static files # 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 @asynccontextmanager
@@ -27,5 +29,5 @@ async def health_check():
return {"status": "ok"} 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, "/") frontend.route(app, "/")
+266 -48
View File
@@ -1,64 +1,283 @@
#!/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:
uv run scripts/devserver.py [host:port] [--backend host:port]
The optional host:port argument sets where the Vite frontend listens.
Supported forms: host[:port], :port (all interfaces), or just port.
The --backend option sets where the FastAPI backend listens (default: localhost:5180).
Environment:
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is.
"""
import argparse import argparse
import asyncio import asyncio
import contextlib import contextlib
import ipaddress
import os import os
import sys
from pathlib import Path from pathlib import Path
from sys import stderr
from urllib.parse import urlparse
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path) import httpx
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from devutil import ( # type: ignore exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
ProcessGroup,
logger, DEFAULT_HOST = "localhost"
ready, DEFAULT_VITE_PORT = 5173
setup_fastapi, DEFAULT_BACKEND_PORT = 5180
setup_vite, 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 cannot correctly proxy API requests to the backend.
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
┃ Consider using deno or npm instead for development.
"""
async def run_devserver(frontend: str, backend: str) -> None: def parse_endpoint(
reporoot = Path(__file__).parent.parent value: str | None, default_port: int = DEFAULT_VITE_PORT
front = reporoot / "frontend" ) -> tuple[str | None, int, bool]:
if not (front / "package.json").exists(): """Parse an endpoint for Vite or backend.
logger.warning("Frontend source not found at %s", front)
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
) -> tuple[list[str], list[str], str]:
"""Resolve frontend install and dev commands.
Returns (install_cmd, dev_cmd, tool_name).
Raises SystemExit if tools are not available.
"""
if not (FRONTEND_PATH / "package.json").exists():
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
raise SystemExit(1) raise SystemExit(1)
frontend_url, npm_install, vite = setup_vite(frontend) result = find_js_runtime() # noqa # type: ignore
backend_url, fastapi = setup_fastapi(backend, "MODULE_NAME.APP_MODULE:APP_VAR") if result is None:
if not os.environ.get("JS_RUNTIME"):
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
raise SystemExit(1)
# Tell the everyone where the frontend and backend are (vite proxy, etc) tool, name = result
os.environ["FASTAPI_VUE_FRONTEND_URL"] = frontend_url
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
async with ProcessGroup() as pg: install_args = {
install_proc = await pg.spawn(*npm_install, cwd=front) "deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
await asyncio.sleep(0.2) # reduce message overlap "npm": ("install", "--silent"),
await pg.spawn( "bun": ("install", "--silent"),
*fastapi, }
"--reload", dev_args = {
"--reload-dir=MODULE_NAME", # Don't reload on frontend changes "deno": ("run", "dev", "--"),
"--forwarded-allow-ips=*", "npm": ("--silent", "run", "dev", "--"),
cwd=reporoot, "bun": ("run", "dev", "--"),
}
install_cmd = [tool, *install_args[name]]
dev_cmd = [
tool,
*dev_args[name],
"--clearScreen=false",
f"--port={vite_port}",
]
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)
return install_cmd, dev_cmd, name
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
async with httpx.AsyncClient() as client:
for attempt in range(max_attempts):
try:
await client.get(f"http://{backend_host}:{backend_port}", timeout=1.0)
stderr.write("✓ Backend ready!\n")
return True
except httpx.RequestError:
if attempt == max_attempts - 1:
stderr.write("┃ ⚠️ Backend didn't start in time\n")
return False
await asyncio.sleep(0.1)
return False
async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> None:
"""Gracefully terminate a subprocess."""
if proc.returncode is not None:
return
try:
proc.terminate()
except ProcessLookupError:
return
try:
await asyncio.wait_for(proc.wait(), timeout=2)
except TimeoutError:
try:
proc.kill()
except ProcessLookupError:
return
await proc.wait()
async def run_devserver(
vite_host: str | None,
vite_port: int,
all_ifaces: bool,
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_host, vite_port, all_ifaces
) )
# Wait for both install and backend to be ready # Tell the backend where the Vite dev server is
async with asyncio.TaskGroup() as tg: os.environ["FASTAPI_VUE_FRONTEND_URL"] = (
tg.create_task(pg.wait(install_proc)) f"http://{vite_host or 'localhost'}:{vite_port}"
tg.create_task(ready(backend_url, path="/api/health?from=devserver.py")) )
# 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)
frontend_cwd = str(FRONTEND_PATH)
# Start Vite dev server (ProcessGroup waits for any exit, then terminates others) backend_proc: asyncio.subprocess.Process | None = None
await pg.spawn(*vite, cwd=front) install_proc: asyncio.subprocess.Process | None = None
frontend_proc: asyncio.subprocess.Process | None = None
try:
# Start install (concurrent with backend)
stderr.write(f">>> {tool_name} {' '.join(install_cmd[1:])}\n")
install_proc = await asyncio.create_subprocess_exec(
*install_cmd, cwd=frontend_cwd
)
await asyncio.sleep(0.1)
# Start backend (concurrent with install)
backend_cmd = [
"uvicorn",
"MODULE_NAME.app:app",
"--host",
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)
# Wait for install to complete and backend to be ready
install_task = asyncio.create_task(install_proc.wait(), name="install")
backend_ready_task = asyncio.create_task(
wait_for_backend(backend_host, backend_port), name="backend_ready"
)
done, pending = await asyncio.wait(
{install_task, backend_ready_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in done:
if task.get_name() == "install":
if task.result() != 0:
stderr.write("┃ ⚠️ Install failed\n")
raise SystemExit(1)
elif task.get_name() == "backend_ready" and not task.result():
raise SystemExit(1)
if pending:
done2, _ = await asyncio.wait(pending)
for task in done2:
if task.get_name() == "install":
if task.result() != 0:
stderr.write("┃ ⚠️ Install failed\n")
raise SystemExit(1)
elif task.get_name() == "backend_ready" and not task.result():
raise SystemExit(1)
install_proc = None
# Start Vite dev server
stderr.write(f">>> {tool_name} {' '.join(dev_cmd[1:])}\n")
frontend_proc = await asyncio.create_subprocess_exec(*dev_cmd, cwd=frontend_cwd)
# Wait for either process to exit
done, pending = await asyncio.wait(
{
asyncio.create_task(backend_proc.wait(), name="backend"),
asyncio.create_task(frontend_proc.wait(), name="frontend"),
},
return_when=asyncio.FIRST_COMPLETED,
)
for t in done:
t.result()
for t in pending:
t.cancel()
except asyncio.CancelledError:
stderr.write("\n✓ Shutting down...\n")
finally:
if frontend_proc is not None:
await _terminate_process(frontend_proc, "frontend")
if install_proc is not None:
await _terminate_process(install_proc, "install")
if backend_proc is not None:
await _terminate_process(backend_proc, "backend")
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Run Vite and FastAPI development servers", description="Run Vite and FastAPI development servers",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=HELP_EPILOG, epilog=EPILOG,
) )
parser.add_argument( parser.add_argument(
"frontend", "frontend",
@@ -72,17 +291,16 @@ def main():
help="FastAPI backend endpoint (default: localhost:5180)", help="FastAPI backend endpoint (default: localhost:5180)",
) )
args = parser.parse_args() 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(run_devserver(args.frontend, args.backend)) asyncio.run(
run_devserver(vite_host, vite_port, all_ifaces, backend_host, backend_port)
)
HELP_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
JS_RUNTIME environment variable can be used to select the JS runtime
"""
if __name__ == "__main__": if __name__ == "__main__":
+23 -4
View File
@@ -1,15 +1,34 @@
"""Hatch build hook for building Vue frontend during package build.""" """Hatch build hook for building Vue frontend during package build."""
import sys import subprocess
from pathlib import Path from pathlib import Path
from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
sys.path.insert(0, str(Path(__file__).parent)) exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
from buildutil import build
def run(cmd, **kwargs):
"""Run a command and display it."""
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
stderr.write(f"### {' '.join(display_cmd)}\n")
subprocess.run(cmd, check=True, **kwargs)
class CustomBuildHook(BuildHookInterface): class CustomBuildHook(BuildHookInterface):
"""Build hook that compiles Vue frontend before packaging."""
def initialize(self, version, build_data): def initialize(self, version, build_data):
super().initialize(version, build_data) super().initialize(version, build_data)
build("frontend") stderr.write(">>> Building the frontend\n")
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
try:
run(install_cmd, cwd="frontend")
stderr.write("\n")
run(build_cmd, cwd="frontend")
except Exception as e:
stderr.write(f"Error occurred while building frontend: {e}\n")
raise
+87
View File
@@ -0,0 +1,87 @@
"""Shared utilities for build and dev scripts."""
import os
import shutil
from pathlib import Path
from sys import stderr
def find_js_runtime() -> tuple[str, str] | None:
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
Returns None if no runtime is found.
"""
options = ["deno", "npm", "bun"]
# Check for JS_RUNTIME environment variable
if js_runtime_env := os.environ.get("JS_RUNTIME"):
js_runtime = js_runtime_env
js_path = Path(js_runtime)
runtime_name = js_path.name
# Map node to npm
if runtime_name == "node":
runtime_name = "npm"
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
for option in options:
if option == runtime_name or runtime_name.startswith(option):
tool = shutil.which(js_runtime)
if tool is None:
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not found\n")
return None
return tool, option
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not recognized\n")
return None
# Auto-detect
for option in options:
if tool := shutil.which(option):
return tool, option
return None
def find_build_tool():
"""Find JavaScript runtime and construct install/build commands.
Returns (install_cmd, build_cmd) tuples of command lists.
Raises RuntimeError if no runtime is found.
"""
install = {
"deno": ("install", "--allow-scripts=npm:vue-demi"),
"npm": ("install",),
"bun": ("--bun", "install"),
}
# Run vite directly for deno to avoid npm-run-all2/run-p issues
build = {
"deno": ("run", "-A", "npm:vite", "build"),
"npm": ("run", "build"),
"bun": ("--bun", "run", "build"),
}
result = find_js_runtime()
if result is None:
raise RuntimeError(
"Deno, npm or Bun is required for building but none was found"
)
tool, name = result
return [tool, *install[name]], [tool, *build[name]]
def find_dev_tool():
"""Find JavaScript runtime and construct dev command.
Returns (dev_cmd, tool_name) or (None, None) if not found.
"""
dev_args = {
"deno": ("run", "dev", "--"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}
result = find_js_runtime()
if result is None:
return None, None
tool, name = result
return [tool, *dev_args[name]], name