Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee4e682cfc | ||
|
|
7fc549e2f3 | ||
|
|
1c3dfd4545 | ||
|
|
9208718b3f | ||
|
|
cf1c536f5c | ||
|
|
ed2013c515 | ||
|
|
9d681853e6 | ||
|
|
0c05551d33 | ||
|
|
67b804eaf2 | ||
|
|
863eed8460 | ||
|
|
1690b051c4 | ||
|
|
c6378e6abd | ||
|
|
a28c4ec149 |
@@ -3,3 +3,4 @@
|
|||||||
*.lock
|
*.lock
|
||||||
__pycache__/
|
__pycache__/
|
||||||
dist/
|
dist/
|
||||||
|
/_version.py
|
||||||
|
|||||||
+139
-443
@@ -15,18 +15,20 @@ import os
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from textwrap import indent
|
from textwrap import indent
|
||||||
|
|
||||||
import 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": {
|
||||||
@@ -129,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"]
|
||||||
@@ -138,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"]
|
||||||
@@ -162,153 +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
|
||||||
|
|
||||||
|
|
||||||
@@ -350,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}, "/")'
|
||||||
@@ -376,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")
|
||||||
@@ -393,29 +247,32 @@ def patch_app_file(
|
|||||||
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}")
|
||||||
|
|
||||||
@@ -451,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
|
||||||
|
|
||||||
@@ -493,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}")
|
||||||
@@ -540,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
|
||||||
@@ -559,97 +410,49 @@ 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)")
|
insert_pos = script_end_match.start()
|
||||||
return False
|
content = content[:insert_pos] + script_addition + content[insert_pos:]
|
||||||
|
|
||||||
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
|
# 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
|
last_p = content.rfind(" </p>", 0, template_end)
|
||||||
# Find </p> before </template>
|
if last_p == -1:
|
||||||
last_p = content.rfind("</p>", 0, template_end)
|
return False
|
||||||
if last_p == -1:
|
before, after = content[:last_p], content[last_p:]
|
||||||
print(f"⚠️ Skipping {target_file} (no </p> tag found for status insertion)")
|
content = f"{before}{STATUS_SPAN_TEMPLATE}{after}"
|
||||||
return False
|
|
||||||
before, after = content[:last_p], content[last_p:]
|
|
||||||
content = f"{before}{STATUS_SPAN_TEMPLATE}{after}"
|
|
||||||
|
|
||||||
# Check if content actually changed
|
|
||||||
if content == original_content:
|
|
||||||
print(f"⚠️ Skipping {target_file} (no changes needed)")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print(f"[DRY RUN] Would patch {target_file}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
target_file.write_text(content)
|
target_file.write_text(content)
|
||||||
print(f"✅ Patched {target_file}")
|
print(f"✅ Patched {target_file}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# Track .new.py files written during setup (for merge notification)
|
|
||||||
_new_files_written: list[tuple[Path, Path]] = []
|
|
||||||
|
|
||||||
|
|
||||||
def write_file(
|
def write_file(
|
||||||
path: Path,
|
path: Path,
|
||||||
content: str,
|
content: str,
|
||||||
overwrite: bool = True,
|
overwrite: bool = True,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
executable: bool = False,
|
executable: bool = False,
|
||||||
fallback_path: Path | None = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Write content to a file, handling existing files and dry-run.
|
"""Write content to a file, handling existing files and dry-run."""
|
||||||
|
|
||||||
If fallback_path is provided and the file exists without the upgrade marker,
|
|
||||||
the content will be written to fallback_path instead of being skipped.
|
|
||||||
"""
|
|
||||||
exists = path.exists()
|
exists = path.exists()
|
||||||
if exists and not overwrite:
|
if exists and not overwrite:
|
||||||
print(f"ℹ️ Skipping {path} (exists)")
|
print(f"⚠️ Skipping {path} (exists)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if content is the same
|
|
||||||
if exists:
|
|
||||||
existing_content = path.read_text()
|
|
||||||
if existing_content == content:
|
|
||||||
print(f"✔️ {path} (already up to date)")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# If overwrite requested but file doesn't have upgrade marker
|
|
||||||
if overwrite and UPGRADE_MARKER not in existing_content:
|
|
||||||
if fallback_path is not None:
|
|
||||||
# Write to fallback path instead
|
|
||||||
return _write_fallback_file(
|
|
||||||
path, fallback_path, content, dry_run, executable
|
|
||||||
)
|
|
||||||
print(f"ℹ️ Skipping {path} (customized by user)")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
action = "overwrite" if exists else "create"
|
action = "overwrite" if exists else "create"
|
||||||
print(f"[DRY RUN] Would {action} {path}")
|
print(f"[DRY RUN] Would {action} {path}")
|
||||||
@@ -664,113 +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"]
|
|
||||||
]
|
|
||||||
|
|
||||||
# Set artifacts if not already set
|
result["tool"]["hatch"]["build"]["targets"]["sdist"] = hatch_build["targets"][
|
||||||
if "artifacts" not in hatch_build:
|
"sdist"
|
||||||
hatch_build["artifacts"] = [
|
]
|
||||||
a.replace("MODULE_NAME", module_name) for a in hatch_additions["artifacts"]
|
|
||||||
]
|
|
||||||
|
|
||||||
# Set only-packages if not already set
|
return result
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -947,7 +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 = find_fastapi_app(module_dir, project_dir) if module_dir.exists() else None
|
app_info = find_fastapi_app(module_dir) if module_dir.exists() else None
|
||||||
|
|
||||||
if app_info:
|
if app_info:
|
||||||
app_file, app_var = app_info
|
app_file, app_var = app_info
|
||||||
@@ -968,40 +705,27 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
module_dir.mkdir(parents=True)
|
module_dir.mkdir(parents=True)
|
||||||
|
|
||||||
# === Install scripts (always update our own scripts) ===
|
# === Install scripts (always update our own scripts) ===
|
||||||
# util.py and build-frontend.py are internal and always overwritten
|
script_files = [
|
||||||
# devserver.py can be customized, so use fallback if needed
|
|
||||||
internal_script_files = [
|
|
||||||
(fastapi_vue_scripts / "util.py", "scripts/fastapi-vue/util.py"),
|
(fastapi_vue_scripts / "util.py", "scripts/fastapi-vue/util.py"),
|
||||||
(
|
(
|
||||||
fastapi_vue_scripts / "build-frontend.py",
|
fastapi_vue_scripts / "build-frontend.py",
|
||||||
"scripts/fastapi-vue/build-frontend.py",
|
"scripts/fastapi-vue/build-frontend.py",
|
||||||
),
|
),
|
||||||
|
(scripts_dir / "devserver.py", "scripts/devserver.py"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for dest_path, template_path in internal_script_files:
|
for dest_path, template_path in script_files:
|
||||||
template = load_template(template_path)
|
template = load_template(template_path)
|
||||||
content = render_template(template, **tpl_vars)
|
content = render_template(template, **tpl_vars)
|
||||||
|
is_executable = dest_path.name == "devserver.py"
|
||||||
write_file(
|
write_file(
|
||||||
dest_path,
|
dest_path,
|
||||||
content,
|
content,
|
||||||
overwrite=True,
|
overwrite=True,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
|
executable=is_executable,
|
||||||
)
|
)
|
||||||
|
|
||||||
# devserver.py - use fallback if customized by user
|
|
||||||
devserver_path = scripts_dir / "devserver.py"
|
|
||||||
devserver_fallback = scripts_dir / "devserver.new.py"
|
|
||||||
template = load_template("scripts/devserver.py")
|
|
||||||
content = render_template(template, **tpl_vars)
|
|
||||||
write_file(
|
|
||||||
devserver_path,
|
|
||||||
content,
|
|
||||||
overwrite=True,
|
|
||||||
dry_run=dry_run,
|
|
||||||
executable=True,
|
|
||||||
fallback_path=devserver_fallback,
|
|
||||||
)
|
|
||||||
|
|
||||||
# === Handle app module ===
|
# === Handle app module ===
|
||||||
if app_file:
|
if app_file:
|
||||||
# Existing app: patch with import, route, and try to patch lifespan
|
# Existing app: patch with import, route, and try to patch lifespan
|
||||||
@@ -1022,27 +746,11 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
write_file(app_file_path, content, overwrite=False, dry_run=dry_run)
|
write_file(app_file_path, content, overwrite=False, dry_run=dry_run)
|
||||||
|
|
||||||
# === Handle __main__.py ===
|
# === Handle __main__.py ===
|
||||||
# Skip if project already has a CLI entrypoint in pyproject.toml
|
main_file = module_dir / "__main__.py"
|
||||||
existing_cli = _find_existing_cli_entrypoint(project_dir, module_name)
|
if not main_file.exists():
|
||||||
if existing_cli:
|
|
||||||
print(f"ℹ️ Using existing CLI entrypoint: {existing_cli}")
|
|
||||||
else:
|
|
||||||
main_file = module_dir / "__main__.py"
|
|
||||||
main_fallback = module_dir / "__main__.new.py"
|
|
||||||
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)
|
||||||
# Use write_file with fallback - it handles all cases:
|
|
||||||
# - File doesn't exist: create it
|
|
||||||
# - File exists with marker: update it
|
|
||||||
# - File exists without marker: write to fallback
|
|
||||||
write_file(
|
|
||||||
main_file,
|
|
||||||
main_content,
|
|
||||||
overwrite=True,
|
|
||||||
dry_run=dry_run,
|
|
||||||
fallback_path=main_fallback,
|
|
||||||
)
|
|
||||||
|
|
||||||
# === Update vite.config.js/ts ===
|
# === Update vite.config.js/ts ===
|
||||||
frontend_dir = project_dir / "frontend"
|
frontend_dir = project_dir / "frontend"
|
||||||
@@ -1071,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,
|
||||||
)
|
)
|
||||||
@@ -1116,18 +820,18 @@ 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:
|
with open(gitignore_path, "a") as f:
|
||||||
nl = b"\r\n" if b"\r\n" in gitignore_content else b"\n"
|
if not gitignore_content.endswith("\n"):
|
||||||
suffix = b"" if gitignore_content.endswith(nl) else nl
|
f.write("\n")
|
||||||
gitignore_path.write_bytes(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}")
|
||||||
else:
|
else:
|
||||||
@@ -1148,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
|
||||||
|
|
||||||
|
|
||||||
@@ -1210,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
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
#!/usr/bin/env -S uv run
|
#!/usr/bin/env -S uv run
|
||||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
|
||||||
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@@ -17,15 +16,17 @@ Environment:
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sys import stderr
|
from sys import stderr
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
|
||||||
|
|
||||||
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
||||||
|
|
||||||
|
DEFAULT_HOST = "localhost"
|
||||||
DEFAULT_VITE_PORT = 5173
|
DEFAULT_VITE_PORT = 5173
|
||||||
DEFAULT_BACKEND_PORT = 5180
|
DEFAULT_BACKEND_PORT = 5180
|
||||||
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
||||||
@@ -44,8 +45,45 @@ BUN_BUG = """\
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_endpoint(
|
||||||
|
value: str | None, default_port: int = DEFAULT_VITE_PORT
|
||||||
|
) -> tuple[str | None, int, bool]:
|
||||||
|
"""Parse an endpoint for Vite or backend.
|
||||||
|
|
||||||
|
Returns (host, port, all_ifaces).
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return DEFAULT_HOST, default_port, False
|
||||||
|
|
||||||
|
# Port only (numeric) -> localhost:port
|
||||||
|
if value.isdigit():
|
||||||
|
return DEFAULT_HOST, int(value), False
|
||||||
|
|
||||||
|
# Leading colon :port -> bind all interfaces
|
||||||
|
if value.startswith(":") and value != ":":
|
||||||
|
port_part = value[1:]
|
||||||
|
if not port_part.isdigit():
|
||||||
|
raise SystemExit(f"Invalid port in '{value}'")
|
||||||
|
return None, int(port_part), True
|
||||||
|
|
||||||
|
# Unbracketed IPv6 (cannot safely contain a port)
|
||||||
|
if value.count(":") > 1 and not value.startswith("["):
|
||||||
|
try:
|
||||||
|
ipaddress.IPv6Address(value)
|
||||||
|
except ValueError as e:
|
||||||
|
raise SystemExit(f"Invalid IPv6 address '{value}': {e}") from e
|
||||||
|
return value, default_port, False
|
||||||
|
|
||||||
|
# Use urllib.parse for everything else
|
||||||
|
parsed = urlparse(f"//{value}")
|
||||||
|
host = parsed.hostname or DEFAULT_HOST
|
||||||
|
port = parsed.port or default_port
|
||||||
|
|
||||||
|
return host, port, False
|
||||||
|
|
||||||
|
|
||||||
def resolve_frontend_tools(
|
def resolve_frontend_tools(
|
||||||
vite_port: int, all_ifaces: bool
|
vite_host: str | None, vite_port: int, all_ifaces: bool
|
||||||
) -> tuple[list[str], list[str], str]:
|
) -> tuple[list[str], list[str], str]:
|
||||||
"""Resolve frontend install and dev commands.
|
"""Resolve frontend install and dev commands.
|
||||||
|
|
||||||
@@ -85,6 +123,8 @@ def resolve_frontend_tools(
|
|||||||
|
|
||||||
if all_ifaces:
|
if all_ifaces:
|
||||||
dev_cmd.append("--host")
|
dev_cmd.append("--host")
|
||||||
|
elif vite_host and vite_host != "localhost":
|
||||||
|
dev_cmd.append(f"--host={vite_host}")
|
||||||
|
|
||||||
if name == "bun":
|
if name == "bun":
|
||||||
stderr.write(BUN_BUG)
|
stderr.write(BUN_BUG)
|
||||||
@@ -92,15 +132,13 @@ def resolve_frontend_tools(
|
|||||||
return install_cmd, dev_cmd, name
|
return install_cmd, dev_cmd, name
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_backend(host: str, port: int):
|
async def wait_for_backend(backend_host: str, backend_port: int):
|
||||||
"""Wait for the backend to be ready by polling the health endpoint."""
|
"""Wait for the backend to be ready by polling the health endpoint."""
|
||||||
max_attempts = 50
|
max_attempts = 50
|
||||||
url = f"http://{host}:{port}"
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
try:
|
try:
|
||||||
await client.get(url, timeout=1.0)
|
await client.get(f"http://{backend_host}:{backend_port}", timeout=1.0)
|
||||||
stderr.write("✓ Backend ready!\n")
|
stderr.write("✓ Backend ready!\n")
|
||||||
return True
|
return True
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
@@ -130,29 +168,23 @@ async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> Non
|
|||||||
|
|
||||||
|
|
||||||
async def run_devserver(
|
async def run_devserver(
|
||||||
|
vite_host: str | None,
|
||||||
vite_port: int,
|
vite_port: int,
|
||||||
all_ifaces: bool,
|
all_ifaces: bool,
|
||||||
backend_host: str,
|
backend_host: str,
|
||||||
backend_port: int,
|
backend_port: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run the development server with install, backend, and frontend."""
|
"""Run the development server with install, backend, and frontend."""
|
||||||
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port, all_ifaces)
|
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(
|
||||||
|
vite_host, vite_port, all_ifaces
|
||||||
|
)
|
||||||
|
|
||||||
# Tell the backend where the Vite dev server is
|
# Tell the backend where the Vite dev server is
|
||||||
os.environ["FASTAPI_VUE_FRONTEND_URL"] = f"http://localhost:{vite_port}"
|
os.environ["FASTAPI_VUE_FRONTEND_URL"] = (
|
||||||
|
f"http://{vite_host or 'localhost'}:{vite_port}"
|
||||||
|
)
|
||||||
# Tell Vite where the backend is (for proxying /api requests)
|
# Tell Vite where the backend is (for proxying /api requests)
|
||||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
|
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
|
||||||
|
|
||||||
backend_cmd = [
|
|
||||||
"uvicorn",
|
|
||||||
"MODULE_NAME.app:app",
|
|
||||||
"--host",
|
|
||||||
backend_host,
|
|
||||||
"--port",
|
|
||||||
str(backend_port),
|
|
||||||
"--reload",
|
|
||||||
]
|
|
||||||
|
|
||||||
cwd = str(Path(__file__).parent.parent)
|
cwd = str(Path(__file__).parent.parent)
|
||||||
frontend_cwd = str(FRONTEND_PATH)
|
frontend_cwd = str(FRONTEND_PATH)
|
||||||
|
|
||||||
@@ -170,6 +202,15 @@ async def run_devserver(
|
|||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# Start backend (concurrent with install)
|
# Start backend (concurrent with install)
|
||||||
|
backend_cmd = [
|
||||||
|
"uvicorn",
|
||||||
|
"MODULE_NAME.app:app",
|
||||||
|
"--host",
|
||||||
|
backend_host,
|
||||||
|
"--port",
|
||||||
|
str(backend_port),
|
||||||
|
"--reload",
|
||||||
|
]
|
||||||
stderr.write(f">>> {' '.join(backend_cmd)}\n")
|
stderr.write(f">>> {' '.join(backend_cmd)}\n")
|
||||||
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
|
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
|
||||||
|
|
||||||
@@ -251,26 +292,15 @@ def main():
|
|||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# parse_endpoint returns list of dicts with host/port or uds keys
|
vite_host, vite_port, all_ifaces = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
|
||||||
# Multiple entries means bind all interfaces (IPv4 + IPv6)
|
backend_host, backend_port, _ = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
|
||||||
vite_endpoints = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
|
# Backend host defaults to localhost (never None)
|
||||||
backend_endpoints = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
|
backend_host = backend_host or DEFAULT_HOST
|
||||||
|
|
||||||
# Vite doesn't support unix sockets
|
|
||||||
if "uds" in vite_endpoints[0]:
|
|
||||||
stderr.write("┃ ⚠️ Unix sockets not supported for frontend\n")
|
|
||||||
raise SystemExit(1)
|
|
||||||
if "uds" in backend_endpoints[0]:
|
|
||||||
stderr.write("┃ ⚠️ Unix sockets not supported for backend\n")
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
vite_port = vite_endpoints[0]["port"]
|
|
||||||
all_ifaces = len(vite_endpoints) > 1
|
|
||||||
backend_host = backend_endpoints[0]["host"]
|
|
||||||
backend_port = backend_endpoints[0]["port"]
|
|
||||||
|
|
||||||
with contextlib.suppress(KeyboardInterrupt):
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
asyncio.run(run_devserver(vite_port, all_ifaces, backend_host, backend_port))
|
asyncio.run(
|
||||||
|
run_devserver(vite_host, vite_port, all_ifaces, backend_host, backend_port)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user