Compare commits

..
6 Commits
6 changed files with 165 additions and 57 deletions
+1 -1
View File
@@ -276,6 +276,6 @@ def _devmode_respond(request: Request, name=""):
return JSONResponse(
status_code=409,
content={
"detail": "Frontend assets served by Vite in debug mode. You are on backend, connect to frontend instead."
"detail": "[devmode] Not serving frontend files here. Should you connect to Vite instead?"
},
)
+159 -52
View File
@@ -26,9 +26,6 @@ import tomlkit
version = importlib.metadata.version("fastapi-vue-setup")
# Track Python files written/patched for ruff formatting
_python_files_to_format: list[Path] = []
# Template directory
TEMPLATE_DIR = Path(__file__).parent / "template"
@@ -41,21 +38,49 @@ def print_boxed(text: str) -> None:
print(f"{'' * width}")
def ruff_sort_imports(files: list[Path], dry: bool = False) -> None:
"""Run ruff to sort imports in the given Python files."""
if not files:
return
py_files = [str(f) for f in files if f.suffix == ".py" and f.exists()]
if not py_files:
return
if dry:
print(f"🔧 Would run ruff import sorting on {len(py_files)} files")
return
print("🔧 Ruff isort on modified files")
subprocess.run(
[sys.executable, "-m", "ruff", "check", "--select", "I", "--fix", *py_files],
stdout=subprocess.DEVNULL,
)
def ruff_format_content(content: str, target_path: Path) -> str:
"""Format Python content using ruff with project settings.
Writes to a temp file (.new.py) next to target, runs ruff check (import sorting)
and ruff format on it, reads back the result, and cleans up.
Returns the formatted content, or original if ruff fails.
"""
temp_file = target_path.with_suffix(".new.py")
try:
temp_file.write_text(content, "UTF-8", newline="\n")
# Sort imports first
subprocess.run(
[
"uv",
"run",
"--with",
"ruff",
"ruff",
"check",
"--select",
"I",
"--fix",
str(temp_file),
],
cwd=target_path.parent,
capture_output=True,
)
# Then format
result = subprocess.run(
["uv", "run", "--with", "ruff", "ruff", "format", str(temp_file)],
cwd=target_path.parent,
capture_output=True,
)
if result.returncode == 0:
return temp_file.read_text("UTF-8")
except Exception:
pass
finally:
try:
temp_file.unlink(missing_ok=True)
except Exception:
pass
return content
def uv_add_packages(
@@ -402,10 +427,43 @@ def _find_app_in_subpackage(subpkg_dir: Path) -> tuple[Path, str] | None:
return None
def _find_existing_cli_entrypoint(project_dir: Path, module_name: str) -> str | None:
def _add_devmode_to_main(content: str) -> str:
"""Add DEVMODE variable to an existing main module."""
lines = content.splitlines()
# Check if os is imported
has_os_import = any("import os" in line for line in lines)
# Find the first import line
insert_idx = 0
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("import ") or stripped.startswith("from "):
insert_idx = i + 1
elif stripped and not stripped.startswith("#"):
break
# Insert imports and DEVMODE after existing imports
new_lines = []
if not has_os_import:
new_lines.append("import os")
new_lines.extend(
[
"",
"# Added by fastapi-vue-setup",
'DEVMODE = os.getenv("ENVPREFIX_DEV") == "1"',
"",
]
)
lines[insert_idx:insert_idx] = new_lines
return "\n".join(lines)
def _find_existing_cli_module_path(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.
Returns the module path (e.g., 'module.subpkg.__main__') if found, None otherwise.
"""
pyproject = project_dir / "pyproject.toml"
if not pyproject.exists():
@@ -423,7 +481,10 @@ def _find_existing_cli_entrypoint(project_dir: Path, module_name: str) -> str |
# 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}"'
# Extract module path from "module.subpkg.__main__:main"
if ":" in entry:
module_path, _ = entry.rsplit(":", 1)
return module_path
return None
@@ -492,7 +553,7 @@ def render_template(template: str, **kwargs) -> str:
def patch_app_file(
path: Path, module_name: str, app_var: str, dry: bool = False
path: Path, main_module_path: str, app_var: str, dry: bool = False
) -> bool:
"""Patch an existing app.py with frontend integration.
@@ -510,7 +571,7 @@ def patch_app_file(
# Check what's already patched
has_frontend = "from fastapi_vue import Frontend" in content
has_devmode = f"from {module_name}.__main__ import DEVMODE" in content
has_devmode = f"from {main_module_path} import DEVMODE" in content
has_debug_arg = re.search(r"FastAPI\s*\([^)]*debug\s*=", content) is not None
has_lifespan = "await frontend.load()" in content
@@ -525,7 +586,7 @@ def patch_app_file(
if not has_frontend:
imports.extend(["from pathlib import Path", "from fastapi_vue import Frontend"])
if not has_devmode:
imports.append(f"from {module_name}.__main__ import DEVMODE")
imports.append(f"from {main_module_path} import DEVMODE")
if imports:
insert_line = find_import_insertion_line(content)
lines = content.splitlines(keepends=True)
@@ -655,7 +716,6 @@ def patch_app_file(
return True
path.write_text(content, "UTF-8", newline="\n")
_python_files_to_format.append(path)
print(f"✅ Patched {path}")
if not lifespan_patched:
@@ -903,13 +963,20 @@ def write_file(
the content will be written to fallback_path instead of being skipped.
If force=True, always overwrite without checking for upgrade marker.
Python files (.py) are automatically formatted using ruff with the project's
settings before writing.
"""
# Format Python content using project settings before any comparison/writing
if path.suffix == ".py":
content = ruff_format_content(content, path)
exists = path.exists()
if exists and not overwrite:
print(f"⚠️ Skipping {path} (exists)")
return False
# Check if content is the same
# Check if content is the same (new content already formatted)
if exists:
existing_content = path.read_text("UTF-8")
if existing_content == content:
@@ -935,8 +1002,6 @@ def write_file(
path.write_text(content, "UTF-8", newline="\n")
if executable and sys.platform != "win32":
path.chmod(path.stat().st_mode | 0o111)
if path.suffix == ".py":
_python_files_to_format.append(path)
action = "Updated" if exists else "Created"
print(f"{action} {path}")
return True
@@ -949,11 +1014,16 @@ def _write_fallback_file(
dry: 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
"""Write content to a fallback .new.py file when original can't be overwritten.
Note: content should already be formatted before calling this function.
"""
# Check if fallback already has same content (content already formatted)
if fallback_path.exists():
if fallback_path.read_text("UTF-8") == content:
existing_content = fallback_path.read_text("UTF-8")
if existing_content == content:
print(f"✔️ {fallback_path} (already up to date)")
_new_files_written.append((fallback_path, original_path))
return False
if dry:
@@ -965,8 +1035,6 @@ def _write_fallback_file(
fallback_path.write_text(content, "UTF-8", newline="\n")
if executable and sys.platform != "win32":
fallback_path.chmod(fallback_path.stat().st_mode | 0o111)
if fallback_path.suffix == ".py":
_python_files_to_format.append(fallback_path)
print(f"✅ Created {fallback_path} (original customized by user)")
_new_files_written.append((fallback_path, original_path))
return True
@@ -1237,6 +1305,21 @@ def cmd_setup(args: argparse.Namespace) -> int:
# Title for templates
project_title = module_name.replace("_", " ").title()
module_dir = project_dir / module_name
scripts_dir = project_dir / "scripts"
fastapi_vue_scripts = scripts_dir / "fastapi-vue"
# Find existing FastAPI app
app_info = (
find_fastapi_app(module_dir, project_dir) if module_dir.exists() else None
)
# Check if project already has a CLI entrypoint in pyproject.toml
existing_cli_module = _find_existing_cli_module_path(project_dir, module_name)
# Determine main module path for DEVMODE import
main_module_path = existing_cli_module or f"{module_name}.__main__"
# Template variables
tpl_vars = {
"MODULE_NAME": module_name,
@@ -1246,17 +1329,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
"TEMPLATE_DEV_PORT": str(dev_port),
"ENVPREFIX": module_name.upper(),
"PROJECT_CLI": module_name,
"MAIN_MODULE": main_module_path,
}
module_dir = project_dir / module_name
scripts_dir = project_dir / "scripts"
fastapi_vue_scripts = scripts_dir / "fastapi-vue"
# Find existing FastAPI app
app_info = (
find_fastapi_app(module_dir, project_dir) if module_dir.exists() else None
)
if app_info:
app_file, app_var = app_info
print(f"📍 Found FastAPI app: {app_var} in {app_file.name}")
@@ -1320,7 +1395,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
# === Handle app module ===
if app_file:
# Existing app: patch with import, route, and try to patch lifespan
patch_app_file(app_file, module_name, app_var, dry=dry)
patch_app_file(app_file, main_module_path, app_var, dry=dry)
else:
# No app: create full app.py
# Create __init__.py if missing
@@ -1337,14 +1412,19 @@ def cmd_setup(args: argparse.Namespace) -> int:
write_file(app_file_path, content, overwrite=False, dry=dry)
# === Handle __main__.py ===
if existing_cli_module:
# Existing CLI entrypoint - write .new.py beside the existing main module
main_module_parts = existing_cli_module.split(".")
main_module_file = project_dir / ("/".join(main_module_parts) + ".py")
main_fallback = main_module_file.with_suffix(".new.py")
else:
main_module_file = None
main_fallback = module_dir / "__main__.new.py"
main_file = module_dir / "__main__.py"
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(
@@ -1354,7 +1434,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
dry=dry,
fallback_path=main_fallback,
)
elif not existing_cli:
elif not existing_cli_module:
# No file and no existing entrypoint: create new __main__.py
write_file(
main_file,
@@ -1363,8 +1443,30 @@ def cmd_setup(args: argparse.Namespace) -> int:
dry=dry,
)
else:
# No file but has existing entrypoint - don't create (user has custom CLI setup)
print(f"️ Skipping __main__.py (package already has CLI: {existing_cli})")
# Existing CLI entrypoint: write our template as .new.py beside the existing module
# and also patch the existing module with DEVMODE if needed
print(f"️ Using existing CLI: {existing_cli_module}")
_write_fallback_file(
main_module_file or main_file,
main_fallback,
main_content,
dry=dry,
executable=False,
)
if main_module_file and main_module_file.exists():
content = main_module_file.read_text("UTF-8")
if "DEVMODE" not in content:
new_content = _add_devmode_to_main(content)
new_file = main_module_file.with_suffix(".new.py")
_write_fallback_file(
main_module_file,
new_file,
new_content,
dry=dry,
executable=False,
)
elif main_module_file:
print(f"⚠️ Existing CLI main module {main_module_file} not found")
# === Update vite.config.js/ts ===
frontend_dir = project_dir / "frontend"
@@ -1440,7 +1542,6 @@ def cmd_setup(args: argparse.Namespace) -> int:
print("✅ Created .gitignore")
# === Add dependencies using uv ===
ruff_sort_imports(_python_files_to_format, dry=dry)
if dry:
print("📦 Would add: fastapi[standard], fastapi-vue, httpx (dev only)")
else:
@@ -1468,7 +1569,13 @@ def cmd_setup(args: argparse.Namespace) -> int:
)
print(" Please manually merge the following files:")
for new_file, original_file in _new_files_written:
print(f"{new_file.name}{original_file.name}")
try:
new_rel = new_file.relative_to(project_dir)
orig_rel = original_file.relative_to(project_dir)
except ValueError:
new_rel = new_file.name
orig_rel = original_file.name
print(f"{new_rel}{orig_rel}")
print()
# Clear the list for potential subsequent runs
_new_files_written.clear()
+1 -1
View File
@@ -5,7 +5,7 @@ import os
from fastapi_vue import server
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
DEVMODE = bool(os.getenv("ENVPREFIX_DEV") == "1")
DEVMODE = os.getenv("ENVPREFIX_DEV") == "1"
def main():
+1 -1
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi_vue import Frontend
from MODULE_NAME.__main__ import DEVMODE
from MAIN_MODULE import DEVMODE
# Vue Frontend static files
frontend = Frontend(Path(__file__).with_name("frontend-build"))
+1 -1
View File
@@ -11,7 +11,7 @@
*/
export default function fastapiVue({ paths = ["/api"] } = {}) {
const backendUrl = process.env.ENVPREFIX_BACKEND_URL || "http://localhost:TEMPLATE_DEFAULT_PORT"
const backendUrl = process.env.ENVPREFIX_BACKEND_URL || "http://localhost:TEMPLATE_DEV_PORT"
// Build proxy configuration for each path
const proxy = {}
+2 -1
View File
@@ -56,7 +56,8 @@ def main():
epilog=HELP_EPILOG,
)
parser.add_argument(
"-l", "--listen",
"-l",
"--listen",
metavar="host:port",
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
)