Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c106ce1a44 | ||
|
|
b7b1920846 | ||
|
|
8ccfa1f658 | ||
|
|
c23401de88 | ||
|
|
c271f66615 | ||
|
|
458d798022 | ||
|
|
f5fcd626e8 | ||
|
|
1d9ddf7dc5 | ||
|
|
b189ae66e7 | ||
|
|
4156eb2db3 | ||
|
|
3f6559b2db | ||
|
|
71d4e053a7 | ||
|
|
2dec331afb | ||
|
|
64f43df3e3 | ||
|
|
6ccba2be9f | ||
|
|
2b735528ab | ||
|
|
e6170549f0 | ||
|
|
a2983a62a0 | ||
|
|
d9323f34e1 | ||
|
|
a4379094db | ||
|
|
52ec232ce4 | ||
|
|
a6ef9b7c8f | ||
|
|
44e06df66c |
@@ -3,4 +3,3 @@
|
||||
*.lock
|
||||
__pycache__/
|
||||
dist/
|
||||
/_version.py
|
||||
|
||||
+504
-183
@@ -13,22 +13,21 @@ Options:
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from textwrap import indent
|
||||
|
||||
import tomli_w
|
||||
|
||||
try:
|
||||
from _version import __version__
|
||||
except ImportError:
|
||||
__version__ = "0.0.0+unknown"
|
||||
import tomlkit
|
||||
|
||||
# Template directory
|
||||
TEMPLATE_DIR = Path(__file__).parent / "template"
|
||||
|
||||
# Marker comment indicating file can be auto-upgraded
|
||||
# Users should remove this line to prevent automatic updates
|
||||
UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup"
|
||||
|
||||
# pyproject.toml additions for patched projects
|
||||
PYPROJECT_ADDITIONS = {
|
||||
"tool": {
|
||||
@@ -53,9 +52,7 @@ PYPROJECT_ADDITIONS = {
|
||||
# Frontend instantiation block for patching existing apps
|
||||
FRONTEND_BLOCK = """
|
||||
# Vue Frontend static files
|
||||
frontend = Frontend(
|
||||
Path(__file__).with_name("frontend-build"), spa=True, cached=["/assets/"]
|
||||
)
|
||||
frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"])
|
||||
"""
|
||||
|
||||
# TypeScript health check script for Vue components
|
||||
@@ -122,7 +119,7 @@ Next steps:
|
||||
|
||||
def load_template(path: str) -> str:
|
||||
"""Load a template file from the template directory."""
|
||||
return (TEMPLATE_DIR / path).read_text()
|
||||
return (TEMPLATE_DIR / path).read_text("UTF-8")
|
||||
|
||||
|
||||
def find_module_name(project_dir: Path) -> str | None:
|
||||
@@ -131,8 +128,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("UTF-8"))
|
||||
|
||||
if "project" in data and "name" in data["project"]:
|
||||
name = data["project"]["name"]
|
||||
@@ -141,10 +137,18 @@ def find_module_name(project_dir: Path) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def find_fastapi_app(module_dir: Path) -> tuple[Path, str] | None:
|
||||
def find_fastapi_app(
|
||||
module_dir: Path, project_dir: Path | None = None
|
||||
) -> tuple[Path, str] | None:
|
||||
"""Find the FastAPI app in a module directory.
|
||||
|
||||
Returns (file_path, app_variable_name) or None if not found.
|
||||
|
||||
Search order:
|
||||
1. Common app files in module_dir (app.py, main.py, etc.)
|
||||
2. All .py files in module_dir
|
||||
3. Subpackage indicated by CLI entrypoint in pyproject.toml
|
||||
4. Follow re-exports in __init__.py files
|
||||
"""
|
||||
# Common app file names to check first
|
||||
candidates = ["app.py", "main.py", "server.py", "api.py", "__init__.py"]
|
||||
@@ -157,20 +161,162 @@ def find_fastapi_app(module_dir: Path) -> tuple[Path, str] | None:
|
||||
if result:
|
||||
return path, result
|
||||
|
||||
# Then check all .py files
|
||||
# Then check all .py files in module_dir
|
||||
for path in module_dir.glob("*.py"):
|
||||
if path.name not in candidates:
|
||||
result = _find_app_in_file(path)
|
||||
if result:
|
||||
return path, result
|
||||
|
||||
# Try to find app via CLI entrypoint in pyproject.toml
|
||||
if project_dir:
|
||||
result = _find_app_via_entrypoint(module_dir, project_dir)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_app_via_entrypoint(
|
||||
module_dir: Path, project_dir: Path
|
||||
) -> tuple[Path, str] | None:
|
||||
"""Find FastAPI app by following the CLI entrypoint in pyproject.toml.
|
||||
|
||||
If pyproject.toml has a script like `myapp = "myapp.subpkg.__main__:main"`,
|
||||
look in myapp/subpkg/ for the app (checking __init__.py exports and common files).
|
||||
"""
|
||||
pyproject = project_dir / "pyproject.toml"
|
||||
if not pyproject.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = tomlkit.parse(pyproject.read_text("UTF-8"))
|
||||
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("UTF-8"))
|
||||
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("UTF-8")
|
||||
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
|
||||
|
||||
|
||||
def _find_app_in_file(path: Path) -> str | None:
|
||||
"""Find FastAPI app variable name in a file."""
|
||||
try:
|
||||
content = path.read_text()
|
||||
content = path.read_text("UTF-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -205,19 +351,15 @@ def patch_app_file(
|
||||
print(f"❌ Cannot patch {path} - file not found")
|
||||
return False
|
||||
|
||||
content = path.read_text()
|
||||
original_content = path.read_text("UTF-8")
|
||||
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}, "/")'
|
||||
@@ -235,6 +377,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")
|
||||
@@ -244,36 +391,34 @@ def patch_app_file(
|
||||
|
||||
# Append route at end
|
||||
lines.append("")
|
||||
lines.append("# Serve the Vue frontend (needs to be last if SPA catch-all is used)")
|
||||
lines.append(route_line)
|
||||
content = "\n".join(lines)
|
||||
|
||||
# Try to patch lifespan function
|
||||
# Try to patch lifespan function - insert await frontend.load() before yield
|
||||
lifespan_patched = False
|
||||
|
||||
# Look for async def lifespan pattern and insert after the opening (and docstring if present)
|
||||
lifespan_pattern = r"(async\s+def\s+lifespan\s*\([^)]*\)\s*(?:->.*?)?:\s*\n)"
|
||||
match = re.search(lifespan_pattern, content)
|
||||
if match:
|
||||
insert_pos = match.end()
|
||||
rest = content[insert_pos:]
|
||||
|
||||
# Detect indentation from the next line
|
||||
indent_match = re.match(r"([ \t]*)", rest)
|
||||
indent = (
|
||||
indent_match.group(1) if indent_match and indent_match.group(1) else " "
|
||||
)
|
||||
|
||||
# Check if there's a docstring and skip past it
|
||||
docstring_pattern = r'^([ \t]*)("""[\s\S]*?"""|\'\'\'\'[\s\S]*?\'\'\')\s*\n'
|
||||
docstring_match = re.match(docstring_pattern, rest)
|
||||
if docstring_match:
|
||||
insert_pos += docstring_match.end()
|
||||
|
||||
# Look for yield inside an async def lifespan function
|
||||
# Find the yield statement and insert before it
|
||||
yield_pattern = r"^([ \t]+)(yield\b)"
|
||||
yield_match = re.search(yield_pattern, content, re.MULTILINE)
|
||||
if yield_match:
|
||||
indent = yield_match.group(1)
|
||||
insert_pos = yield_match.start()
|
||||
load_code = f"{indent}await frontend.load()\n"
|
||||
content = content[:insert_pos] + load_code + content[insert_pos:]
|
||||
lifespan_patched = True
|
||||
|
||||
path.write_text(content)
|
||||
# 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, "UTF-8", newline="\n")
|
||||
print(f"✅ Patched {path}")
|
||||
|
||||
if not lifespan_patched:
|
||||
@@ -308,21 +453,17 @@ def patch_vite_config(
|
||||
print(f"❌ Cannot patch {path} - file not found")
|
||||
return False
|
||||
|
||||
content = path.read_text()
|
||||
original_content = path.read_text("UTF-8")
|
||||
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
|
||||
|
||||
@@ -354,8 +495,20 @@ 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
|
||||
|
||||
path.write_text(content)
|
||||
# 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, "UTF-8", newline="\n")
|
||||
print(f"✅ Patched {path}")
|
||||
return True
|
||||
|
||||
@@ -389,16 +542,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("UTF-8")
|
||||
|
||||
# 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
|
||||
@@ -410,56 +561,107 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
|
||||
|
||||
# Insert script addition before </script>
|
||||
script_end_match = re.search(r"</script>", content)
|
||||
if 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 </script> 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 </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):
|
||||
# Insert before closing </h3>
|
||||
h3_close = content.find(" </h3>")
|
||||
if h3_close == -1:
|
||||
print(
|
||||
f"⚠️ Skipping {target_file} (no </h3> tag found for status insertion)"
|
||||
)
|
||||
return False
|
||||
before, after = content[:h3_close], content[h3_close:]
|
||||
content = f"{before}{indent(STATUS_SPAN_TEMPLATE, ' ')}{after}"
|
||||
else:
|
||||
# Minimal App.vue - 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>")
|
||||
if template_end != -1:
|
||||
# Find last </p> before </template>
|
||||
last_p = content.rfind(" </p>", 0, template_end)
|
||||
if last_p == -1:
|
||||
return False
|
||||
before, after = content[:last_p], content[last_p:]
|
||||
content = f"{before}{STATUS_SPAN_TEMPLATE}{after}"
|
||||
if template_end == -1:
|
||||
print(f"⚠️ Skipping {target_file} (no </template> tag found)")
|
||||
return False
|
||||
# Find </p> before </template>
|
||||
last_p = content.rfind("</p>", 0, template_end)
|
||||
if last_p == -1:
|
||||
print(f"⚠️ Skipping {target_file} (no </p> tag found for status insertion)")
|
||||
return False
|
||||
before, after = content[:last_p], content[last_p:]
|
||||
content = f"{before}{STATUS_SPAN_TEMPLATE}{after}"
|
||||
|
||||
target_file.write_text(content)
|
||||
# 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, "UTF-8", newline="\n")
|
||||
print(f"✅ Patched {target_file}")
|
||||
return True
|
||||
|
||||
|
||||
# Track .new.py files written during setup (for merge notification)
|
||||
_new_files_written: list[tuple[Path, Path]] = []
|
||||
|
||||
|
||||
def write_file(
|
||||
path: Path,
|
||||
content: str,
|
||||
overwrite: bool = True,
|
||||
dry_run: bool = False,
|
||||
executable: bool = False,
|
||||
fallback_path: Path | None = None,
|
||||
force: bool = False,
|
||||
) -> 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()
|
||||
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("UTF-8")
|
||||
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
|
||||
|
||||
if dry_run:
|
||||
action = "overwrite" if exists else "create"
|
||||
print(f"[DRY RUN] Would {action} {path}")
|
||||
return True
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
path.write_text(content, "UTF-8", newline="\n")
|
||||
if executable and sys.platform != "win32":
|
||||
path.chmod(path.stat().st_mode | 0o111)
|
||||
action = "Updated" if exists else "Created"
|
||||
@@ -467,47 +669,114 @@ 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 _write_fallback_file(
|
||||
original_path: Path,
|
||||
fallback_path: Path,
|
||||
content: str,
|
||||
dry_run: bool,
|
||||
executable: bool,
|
||||
) -> bool:
|
||||
"""Write content to a fallback .new.py file when original can't be overwritten."""
|
||||
# Check if fallback already has same content
|
||||
if fallback_path.exists():
|
||||
if fallback_path.read_text("UTF-8") == 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, "UTF-8", newline="\n")
|
||||
if executable and sys.platform != "win32":
|
||||
fallback_path.chmod(fallback_path.stat().st_mode | 0o111)
|
||||
print(f"✅ Created {fallback_path} (original customized by user)")
|
||||
_new_files_written.append((fallback_path, original_path))
|
||||
return True
|
||||
|
||||
|
||||
def merge_pyproject(
|
||||
data: tomlkit.TOMLDocument, additions: dict, module_name: str
|
||||
) -> tomlkit.TOMLDocument:
|
||||
"""Merge additions into pyproject.toml data, preserving comments and existing values."""
|
||||
# Ensure hatchling build system is configured
|
||||
if "build-system" not in 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()
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -521,8 +790,6 @@ def find_js_runtime() -> tuple[str, str] | None:
|
||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||
Returns None if no runtime is found.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
options = ["deno", "npm", "bun"]
|
||||
|
||||
# Check for JS_RUNTIME environment variable
|
||||
@@ -629,6 +896,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
- Patching existing projects with integration files
|
||||
- Updating already-patched projects
|
||||
"""
|
||||
# Clear any existing virtual environment to avoid uv using the wrong one (warning)
|
||||
os.environ.pop("VIRTUAL_ENV", None)
|
||||
|
||||
project_path = Path(args.project_dir)
|
||||
|
||||
# Handle both "." and "/path/to/project"
|
||||
@@ -684,7 +954,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
fastapi_vue_scripts = scripts_dir / "fastapi-vue"
|
||||
|
||||
# Find existing FastAPI app
|
||||
app_info = find_fastapi_app(module_dir) if module_dir.exists() else None
|
||||
app_info = (
|
||||
find_fastapi_app(module_dir, project_dir) if module_dir.exists() else None
|
||||
)
|
||||
|
||||
if app_info:
|
||||
app_file, app_var = app_info
|
||||
@@ -700,31 +972,51 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
|
||||
# Create directories
|
||||
if not dry_run:
|
||||
fastapi_vue_scripts.mkdir(parents=True, exist_ok=True)
|
||||
if not module_dir.exists():
|
||||
module_dir.mkdir(parents=True)
|
||||
fastapi_vue_scripts.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# === Install scripts (always update our own scripts) ===
|
||||
script_files = [
|
||||
(fastapi_vue_scripts / "util.py", "scripts/fastapi-vue/util.py"),
|
||||
(
|
||||
fastapi_vue_scripts / "build-frontend.py",
|
||||
"scripts/fastapi-vue/build-frontend.py",
|
||||
),
|
||||
(scripts_dir / "devserver.py", "scripts/devserver.py"),
|
||||
]
|
||||
# Scripts in fastapi-vue folder are internal and always overwritten
|
||||
# devserver.py can be customized, so use fallback if needed
|
||||
|
||||
for dest_path, template_path in script_files:
|
||||
template = load_template(template_path)
|
||||
content = render_template(template, **tpl_vars)
|
||||
is_executable = dest_path.name == "devserver.py"
|
||||
write_file(
|
||||
dest_path,
|
||||
content,
|
||||
overwrite=True,
|
||||
dry_run=dry_run,
|
||||
executable=is_executable,
|
||||
)
|
||||
# Remove obsolete util.py if present
|
||||
obsolete_util = fastapi_vue_scripts / "util.py"
|
||||
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("UTF-8")
|
||||
content = render_template(template, **tpl_vars)
|
||||
write_file(
|
||||
dest_path,
|
||||
content,
|
||||
overwrite=True,
|
||||
dry_run=dry_run,
|
||||
force=True, # Internal files, always overwrite
|
||||
)
|
||||
|
||||
# devserver.py - use fallback if customized by user
|
||||
devserver_path = scripts_dir / "devserver.py"
|
||||
devserver_fallback = scripts_dir / "devserver.new.py"
|
||||
template = load_template("scripts/devserver.py")
|
||||
content = render_template(template, **tpl_vars)
|
||||
write_file(
|
||||
devserver_path,
|
||||
content,
|
||||
overwrite=True,
|
||||
dry_run=dry_run,
|
||||
executable=True,
|
||||
fallback_path=devserver_fallback,
|
||||
)
|
||||
|
||||
# === Handle app module ===
|
||||
if app_file:
|
||||
@@ -747,10 +1039,31 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
|
||||
# === Handle __main__.py ===
|
||||
main_file = module_dir / "__main__.py"
|
||||
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)
|
||||
main_fallback = module_dir / "__main__.new.py"
|
||||
template = load_template("backend/__main__.py")
|
||||
main_content = render_template(template, **tpl_vars)
|
||||
|
||||
# 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 ===
|
||||
frontend_dir = project_dir / "frontend"
|
||||
@@ -779,65 +1092,65 @@ 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("UTF-8")
|
||||
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("_", "-")
|
||||
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, "UTF-8", newline="\n")
|
||||
print(f"✅ Updated {pyproject_path}")
|
||||
|
||||
# === Add dependencies using uv ===
|
||||
if dry_run:
|
||||
print("[DRY RUN] Would run: uv add 'fastapi[standard]' fastapi-vue")
|
||||
print("[DRY RUN] Would run: uv add --group dev httpx")
|
||||
else:
|
||||
print("📦 Adding dependencies...")
|
||||
result = subprocess.run(
|
||||
["uv", "add", "fastapi[standard]", "fastapi-vue"],
|
||||
cwd=project_dir,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("⚠️ Failed to add main dependencies")
|
||||
result = subprocess.run(
|
||||
["uv", "add", "--group", "dev", "httpx"],
|
||||
cwd=project_dir,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("⚠️ Failed to add dev dependencies")
|
||||
|
||||
# === Update .gitignore ===
|
||||
gitignore_path = project_dir / ".gitignore"
|
||||
gitignore_entry = f"{module_name}/frontend-build/"
|
||||
gitignore_entry = f"/{module_name}/frontend-build"
|
||||
if gitignore_path.exists():
|
||||
gitignore_content = gitignore_path.read_text()
|
||||
if gitignore_entry not in gitignore_content:
|
||||
if dry_run:
|
||||
print(f"[DRY RUN] Would add {gitignore_entry} to .gitignore")
|
||||
else:
|
||||
with open(gitignore_path, "a") as f:
|
||||
if not gitignore_content.endswith("\n"):
|
||||
f.write("\n")
|
||||
f.write(f"{gitignore_entry}\n")
|
||||
print(f"✅ Added {gitignore_entry} to .gitignore")
|
||||
gitignore_content = gitignore_path.read_bytes()
|
||||
if b"frontend-build" in gitignore_content:
|
||||
print("✔️ .gitignore (frontend-build already ignored)")
|
||||
elif dry_run:
|
||||
print(f"[DRY RUN] Would add {gitignore_entry} to .gitignore")
|
||||
else:
|
||||
nl = b"\r\n" if b"\r\n" in gitignore_content else b"\n"
|
||||
suffix = b"" if gitignore_content.endswith(nl) else nl
|
||||
gitignore_path.write_bytes(
|
||||
gitignore_content + suffix + gitignore_entry.encode() + nl
|
||||
)
|
||||
print(f"✅ Added {gitignore_entry} to .gitignore")
|
||||
elif dry_run:
|
||||
print(f"[DRY RUN] Would create .gitignore with {gitignore_entry}")
|
||||
else:
|
||||
gitignore_path.write_text(f"{gitignore_entry}\n")
|
||||
gitignore_path.write_text(f"{gitignore_entry}\n", "UTF-8", newline="\n")
|
||||
print("✅ Created .gitignore")
|
||||
|
||||
# === Add dependencies using uv ===
|
||||
uv_add_main = ["uv", "add", "-q", "-U", "--no-sync", "fastapi[standard]", "fastapi-vue"]
|
||||
uv_add_dev = ["uv", "add", "-q", "-U", "--group", "dev", "httpx"]
|
||||
if dry_run:
|
||||
print(f"[DRY RUN] Would run: {' '.join(uv_add_main)}")
|
||||
print(f"[DRY RUN] Would run: {' '.join(uv_add_dev)}")
|
||||
else:
|
||||
print("📦 Adding dependencies...")
|
||||
result = subprocess.run(uv_add_main, cwd=project_dir, check=False)
|
||||
if result.returncode != 0:
|
||||
print("⚠️ Failed to add main dependencies")
|
||||
result = subprocess.run(uv_add_dev, cwd=project_dir, check=False)
|
||||
if result.returncode != 0:
|
||||
print("⚠️ Failed to add dev dependencies")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("✅ Setup complete!")
|
||||
@@ -852,6 +1165,19 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
)
|
||||
print(message)
|
||||
|
||||
# Show merge note if any .new.py files were written
|
||||
if _new_files_written:
|
||||
print()
|
||||
print(
|
||||
"⚠️ Note: Some files could not be auto-upgraded because you customized them."
|
||||
)
|
||||
print(" Please manually merge the following files:")
|
||||
for new_file, original_file in _new_files_written:
|
||||
print(f" • {new_file.name} → {original_file.name}")
|
||||
print()
|
||||
# Clear the list for potential subsequent runs
|
||||
_new_files_written.clear()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -901,11 +1227,6 @@ Examples:
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="Show what would be done"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=f"%(prog)s {__version__}",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
+1
-4
@@ -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]
|
||||
@@ -22,9 +22,6 @@ fastapi-vue-setup = "fastapi_vue_setup:main"
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.build.hooks.vcs]
|
||||
version-file = "_version.py"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"]
|
||||
|
||||
|
||||
@@ -1,36 +1,26 @@
|
||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
from uvicorn import Config, Server
|
||||
|
||||
from .APP_MODULE import APP_VAR
|
||||
|
||||
DEFAULT_PORT = 5080
|
||||
|
||||
|
||||
def run_server(endpoints: list[dict], *, proxy="", devmode=False):
|
||||
def run_server(endpoints: list[dict], *, proxy=""):
|
||||
conf: dict[str, object] = {"app": "MODULE_NAME.APP_MODULE:APP_VAR"}
|
||||
if proxy:
|
||||
conf["proxy_headers"] = True
|
||||
conf["forwarded_allow_ips"] = proxy
|
||||
if devmode:
|
||||
conf["reload"] = True
|
||||
conf["reload_dirs"] = ["MODULE_NAME"]
|
||||
APP_VAR.debug = True
|
||||
|
||||
if len(endpoints) > 1:
|
||||
# Run separate servers for multiple endpoints
|
||||
async def serve_all():
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
for ep in endpoints:
|
||||
tg.create_task(Server(Config(**conf, **ep)).serve())
|
||||
async def serve_all():
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
for ep in endpoints:
|
||||
tg.create_task(Server(Config(**conf, **ep)).serve())
|
||||
|
||||
asyncio.run(serve_all())
|
||||
else:
|
||||
uvicorn.run(**conf, **endpoints[0])
|
||||
asyncio.run(serve_all())
|
||||
|
||||
|
||||
def main():
|
||||
@@ -45,9 +35,10 @@ def main():
|
||||
)
|
||||
args = parser.parse_args()
|
||||
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
||||
devmode = bool(os.getenv("FASTAPI_VUE_FRONTEND_URL"))
|
||||
endpoints = parse_endpoint(args.endpoint, DEFAULT_PORT)
|
||||
run_server(endpoints, proxy=proxy, devmode=devmode)
|
||||
try:
|
||||
run_server(parse_endpoint(args.endpoint, DEFAULT_PORT), proxy=proxy)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,9 +5,7 @@ from fastapi import FastAPI
|
||||
from fastapi_vue import Frontend
|
||||
|
||||
# Vue Frontend static files
|
||||
frontend = Frontend(
|
||||
Path(__file__).with_name("frontend-build"), spa=True, cached=["/assets/"]
|
||||
)
|
||||
frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"])
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -29,5 +27,5 @@ async def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# Final catch-all route for frontend files (keep at end of file)
|
||||
# Serve the Vue frontend (needs to be last if SPA catch-all is used)
|
||||
frontend.route(app, "/")
|
||||
|
||||
+46
-265
@@ -1,283 +1,63 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""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.
|
||||
"""
|
||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||
"""Run Vite development server for frontend and FastAPI backend with auto-reload."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import ipaddress
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
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"
|
||||
|
||||
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.
|
||||
"""
|
||||
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||
from devutil import ( # type: ignore
|
||||
ProcessGroup,
|
||||
check_ports_free,
|
||||
logger,
|
||||
ready,
|
||||
setup_fastapi,
|
||||
setup_vite,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
) -> 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")
|
||||
async def run_devserver(frontend: str, backend: str) -> None:
|
||||
reporoot = Path(__file__).parent.parent
|
||||
front = reporoot / "frontend"
|
||||
if not (front / "package.json").exists():
|
||||
logger.warning("Frontend source not found at %s", front)
|
||||
raise SystemExit(1)
|
||||
|
||||
result = find_js_runtime() # noqa # type: ignore
|
||||
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)
|
||||
viteurl, npm_install, vite = setup_vite(frontend)
|
||||
backurl, fastapi = setup_fastapi(backend, "MODULE_NAME.APP_MODULE:APP_VAR")
|
||||
|
||||
tool, name = result
|
||||
# Tell the everyone where the frontend and backend are (vite proxy, etc)
|
||||
os.environ["FASTAPI_VUE_FRONTEND_URL"] = viteurl
|
||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = backurl
|
||||
|
||||
install_args = {
|
||||
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install", "--silent"),
|
||||
"bun": ("install", "--silent"),
|
||||
}
|
||||
dev_args = {
|
||||
"deno": ("run", "dev", "--"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"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
|
||||
)
|
||||
|
||||
# Tell the backend where the Vite dev server is
|
||||
os.environ["FASTAPI_VUE_FRONTEND_URL"] = (
|
||||
f"http://{vite_host or 'localhost'}:{vite_port}"
|
||||
)
|
||||
# Tell Vite where the backend is (for proxying /api requests)
|
||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
|
||||
cwd = str(Path(__file__).parent.parent)
|
||||
frontend_cwd = str(FRONTEND_PATH)
|
||||
|
||||
backend_proc: asyncio.subprocess.Process | None = None
|
||||
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),
|
||||
async with ProcessGroup() as pg:
|
||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(
|
||||
*fastapi,
|
||||
"--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"
|
||||
"--reload-dir=MODULE_NAME", # Don't reload on frontend changes
|
||||
"--forwarded-allow-ips=*",
|
||||
cwd=reporoot,
|
||||
)
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
{install_task, backend_ready_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
# Wait for both install and backend to be ready
|
||||
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
|
||||
|
||||
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")
|
||||
# Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
|
||||
await pg.spawn(*vite, cwd=front)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run Vite and FastAPI development servers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=EPILOG,
|
||||
epilog=HELP_EPILOG,
|
||||
)
|
||||
parser.add_argument(
|
||||
"frontend",
|
||||
@@ -291,16 +71,17 @@ def main():
|
||||
help="FastAPI backend endpoint (default: localhost:5180)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
vite_host, vite_port, all_ifaces = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
|
||||
backend_host, backend_port, _ = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
|
||||
# Backend host defaults to localhost (never None)
|
||||
backend_host = backend_host or DEFAULT_HOST
|
||||
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
asyncio.run(
|
||||
run_devserver(vite_host, vite_port, all_ifaces, backend_host, backend_port)
|
||||
)
|
||||
asyncio.run(run_devserver(args.frontend, args.backend))
|
||||
|
||||
|
||||
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__":
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
"""Hatch build hook for building Vue frontend during package build."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
||||
|
||||
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
|
||||
|
||||
|
||||
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)
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from buildutil import build
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface):
|
||||
"""Build hook that compiles Vue frontend before packaging."""
|
||||
|
||||
def initialize(self, version, build_data):
|
||||
super().initialize(version, build_data)
|
||||
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
|
||||
build("frontend")
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Utilities used at build time and in devserver script. No dependencies."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"⚠️ {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
"""Check if Node.js version is >= 20.
|
||||
|
||||
Raises RuntimeError if version is too old or cannot be determined.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[node_path, "--version"], capture_output=True, text=True, check=True
|
||||
)
|
||||
version_str = result.stdout.strip()
|
||||
# Parse version like "v20.10.0" or "v18.17.1"
|
||||
match = re.match(r"v(\d+)", version_str)
|
||||
if match:
|
||||
major_version = int(match.group(1))
|
||||
if major_version >= 20:
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||
pass
|
||||
raise RuntimeError("Could not determine Node.js version")
|
||||
|
||||
|
||||
def find_js_runtime() -> tuple[str, str]:
|
||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||
|
||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||
Raises JSRuntimeError if no suitable runtime is found.
|
||||
"""
|
||||
options = ["npm", "deno", "bun"]
|
||||
node_version_error: RuntimeError | None = None
|
||||
|
||||
# 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:
|
||||
raise RuntimeError(
|
||||
f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||
)
|
||||
# Check Node.js version if using npm
|
||||
if option == "npm":
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
raise RuntimeError(
|
||||
f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||
)
|
||||
_check_node_version(node_path) # Raises on failure
|
||||
return tool, option
|
||||
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
|
||||
|
||||
# Auto-detect
|
||||
for option in options:
|
||||
if tool := shutil.which(option):
|
||||
# Check Node.js version if using npm
|
||||
if option == "npm":
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
continue
|
||||
try:
|
||||
_check_node_version(node_path)
|
||||
except RuntimeError as e:
|
||||
node_version_error = e
|
||||
continue # Try next runtime
|
||||
return tool, option
|
||||
|
||||
# No runtime found - provide helpful error
|
||||
if node_version_error:
|
||||
raise node_version_error
|
||||
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
|
||||
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
return [tool, *install[name]], [tool, *build[name]]
|
||||
|
||||
|
||||
def find_dev_tool() -> list[str]:
|
||||
"""Find JavaScript runtime and construct dev command.
|
||||
|
||||
Returns dev_cmd (without vite-specific args).
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
dev_args = {
|
||||
"deno": ("run", "dev", "--"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"bun": ("run", "dev", "--"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
|
||||
if name == "bun":
|
||||
logger.warning(
|
||||
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
|
||||
)
|
||||
|
||||
return [tool, *dev_args[name]]
|
||||
|
||||
|
||||
def find_install_tool() -> list[str]:
|
||||
"""Find JavaScript runtime and construct install command.
|
||||
|
||||
Returns install_cmd.
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
install_args = {
|
||||
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install", "--silent"),
|
||||
"bun": ("install", "--silent"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
return [tool, *install_args[name]]
|
||||
|
||||
|
||||
def build(folder: str = "frontend") -> None:
|
||||
"""Build the frontend in the specified folder.
|
||||
|
||||
Raises SystemExit(1) on failure.
|
||||
"""
|
||||
logger.info(">>> Building %s", folder)
|
||||
|
||||
try:
|
||||
install_cmd, build_cmd = find_build_tool()
|
||||
except RuntimeError as e:
|
||||
logger.warning(e)
|
||||
raise SystemExit(1)
|
||||
|
||||
def run(cmd):
|
||||
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||
logger.info("### %s", " ".join(display_cmd))
|
||||
subprocess.run(cmd, check=True, cwd=folder)
|
||||
|
||||
try:
|
||||
run(install_cmd)
|
||||
logger.info("")
|
||||
run(build_cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
from collections.abc import Coroutine
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
DEFAULT_VITE_PORT = 5173
|
||||
DEFAULT_BACKEND_PORT = 5180
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
|
||||
def __init__(self):
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
|
||||
async def spawn(
|
||||
self, *cmd: str, cwd: str | None = None
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a subprocess and track it."""
|
||||
cmd_name = Path(cmd[0]).name
|
||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._procs.append(proc)
|
||||
self._cmds[proc.pid] = cmd_name
|
||||
return proc
|
||||
|
||||
async def wait(
|
||||
self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]"
|
||||
) -> None:
|
||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||
|
||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
||||
returncode = await proc.wait()
|
||||
if returncode != 0:
|
||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||
|
||||
tasks = [
|
||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
||||
for w in waitables
|
||||
]
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, *_):
|
||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||
cleanup_task = asyncio.create_task(
|
||||
self._cleanup(immediate=exc_type is not None)
|
||||
)
|
||||
try:
|
||||
await asyncio.shield(cleanup_task)
|
||||
except asyncio.CancelledError:
|
||||
# Shield was cancelled but cleanup_task continues - wait for it
|
||||
await cleanup_task
|
||||
|
||||
async def _cleanup(self, immediate: bool = False):
|
||||
running = [p for p in self._procs if p.returncode is None]
|
||||
if not running:
|
||||
return
|
||||
|
||||
if not immediate:
|
||||
# Wait for any one process to exit
|
||||
await asyncio.wait(
|
||||
[asyncio.create_task(p.wait()) for p in running],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Terminate remaining processes
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
try:
|
||||
p.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
# Wait for all to finish (with overall timeout)
|
||||
still_running = [p for p in self._procs if p.returncode is None]
|
||||
if still_running:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
try:
|
||||
p.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
await p.wait()
|
||||
|
||||
|
||||
async def check_ports_free(*urls: str) -> None:
|
||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
for url in urls:
|
||||
try:
|
||||
res = await client.get(url, timeout=0.1)
|
||||
logger.warning(
|
||||
"Conflicting %s already running at %s",
|
||||
res.headers.get("server", "server"),
|
||||
url,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
except httpx.RequestError:
|
||||
pass # Expected - port is free
|
||||
|
||||
|
||||
async def ready(url: str, path: str = "") -> None:
|
||||
"""Wait for the server to be ready by polling an endpoint.
|
||||
|
||||
Raises SystemExit(1) if server doesn't start in time.
|
||||
"""
|
||||
max_attempts = 50
|
||||
full_url = f"{url}{path}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
await client.get(full_url, timeout=1.0)
|
||||
logger.info("✓ Backend ready!")
|
||||
return
|
||||
except httpx.RequestError:
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]:
|
||||
"""Parse frontend endpoint and build commands.
|
||||
|
||||
Returns (url, install_cmd, dev_cmd).
|
||||
Raises SystemExit(1) on invalid config.
|
||||
"""
|
||||
endpoints = parse_endpoint(endpoint, DEFAULT_VITE_PORT)
|
||||
|
||||
if "uds" in endpoints[0]:
|
||||
logger.warning("Unix sockets not supported with vite devserver")
|
||||
raise SystemExit(1)
|
||||
|
||||
port = endpoints[0]["port"]
|
||||
host = endpoints[0]["host"]
|
||||
|
||||
install_cmd = find_install_tool()
|
||||
dev_cmd = find_dev_tool()
|
||||
if host != "localhost":
|
||||
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
|
||||
if port != 5173:
|
||||
dev_cmd.append(f"--port={port}")
|
||||
|
||||
return f"http://{host}:{port}", install_cmd, dev_cmd
|
||||
|
||||
|
||||
def setup_fastapi(
|
||||
endpoint: str, module: str, default_port: int = DEFAULT_BACKEND_PORT
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend endpoint and build fastapi dev command.
|
||||
|
||||
Returns (url, cmd).
|
||||
Raises SystemExit(1) on invalid config.
|
||||
"""
|
||||
endpoints = parse_endpoint(endpoint, default_port)
|
||||
|
||||
if "uds" in endpoints[0]:
|
||||
logger.warning("Unix sockets not supported with vite devserver")
|
||||
raise SystemExit(1)
|
||||
|
||||
host = endpoints[0]["host"]
|
||||
port = endpoints[0]["port"]
|
||||
|
||||
cmd = [
|
||||
"fastapi",
|
||||
"dev",
|
||||
"--entrypoint",
|
||||
module,
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
str(port),
|
||||
]
|
||||
return f"http://{host}:{port}", cmd
|
||||
@@ -1,87 +0,0 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user