Migrate pre-1.6 DEVMODE to fastapi_vue.env with in-place patching, run uv sync after dependency changes.

This commit is contained in:
2026-09-14 04:22:24 +00:00
parent 42adf6120d
commit 22d9fe350b
3 changed files with 136 additions and 40 deletions
+131 -34
View File
@@ -111,12 +111,14 @@ def ruff_format_content(
def uv_add_packages(packages: list[str], *, cwd: Path, group: str | None = None) -> None: def uv_add_packages(packages: list[str], *, cwd: Path, group: str | None = None) -> None:
"""Add packages using uv.""" """Add packages using uv.
cmd = ["uv", "add", "-q", "-U"]
Uses --frozen so only pyproject.toml is edited, without locking or
syncing - those happen in a single uv sync step after all changes.
"""
cmd = ["uv", "add", "-q", "--frozen"]
if group: if group:
cmd.extend(["--group", group]) cmd.extend(["--group", group])
else:
cmd.append("--no-sync")
cmd.extend(packages) cmd.extend(packages)
result = subprocess.run(cmd, cwd=cwd, check=False) # noqa: S603 result = subprocess.run(cmd, cwd=cwd, check=False) # noqa: S603
if result.returncode != 0: if result.returncode != 0:
@@ -161,7 +163,7 @@ NEW_BUILD_HOOK_PATH = "scripts/fastapi-vue/buildhook.py"
# Frontend instantiation block for patching existing apps # Frontend instantiation block for patching existing apps
FRONTEND_BLOCK = """ FRONTEND_BLOCK = """
# Vue Frontend static files # Vue Frontend static files
frontend = fastapi_vue.Frontend(Path(__file__).with_name("frontend-build")) frontend = Frontend(Path(__file__).with_name("frontend-build"))
""" """
# Lifespan block for patching apps that don't have one # Lifespan block for patching apps that don't have one
@@ -473,6 +475,74 @@ def _find_app_in_subpackage(subpkg_dir: Path) -> tuple[Path, str] | None:
return None return None
def _migrate_devmode_in_main(content: str) -> str | None:
"""Spot-patch the pre-1.6 DEVMODE mechanism to the FASTAPI_VUE env prefix.
Replaces `DEVMODE = os.getenv("PREFIX_DEV") == "1"` with
`os.environ["FASTAPI_VUE"] = "PREFIX"` and remaining DEVMODE references
with env.dev, ensuring env is imported from fastapi_vue.
Returns the patched content, or None if there was nothing to patch.
"""
match = re.search(
r"^DEVMODE\s*=\s*os\.getenv\(\s*[\"']([A-Za-z0-9_]+)_DEV[\"']\s*\)\s*==\s*[\"']1[\"']",
content,
re.MULTILINE,
)
if not match:
return None
content = (
content[: match.start()]
+ f'os.environ["FASTAPI_VUE"] = "{match.group(1)}"'
+ content[match.end() :]
)
# Replace every remaining standalone DEVMODE reference (as in app.py
# migration, string literals are an accepted risk)
content = re.sub(r"(?<![\w.])DEVMODE\b", "env.dev", content)
if not re.search(r"^from fastapi_vue import\b.*\benv\b", content, re.MULTILINE):
if re.search(r"^from fastapi_vue import ", content, re.MULTILINE):
content = re.sub(
r"^from fastapi_vue import ",
"from fastapi_vue import env, ",
content,
count=1,
flags=re.MULTILINE,
)
else:
insert_line = find_import_insertion_line(content)
lines = content.splitlines(keepends=True)
insert_idx = insert_line - 1
import_text = "from fastapi_vue import env\n"
if insert_idx >= len(lines):
content = content.rstrip("\n") + "\n" + import_text
else:
content = "".join(lines[:insert_idx]) + import_text + "".join(lines[insert_idx:])
return content
def _patch_main_devmode(path: Path, *, dry: bool) -> str | None:
"""Apply _migrate_devmode_in_main to a main module in place, if needed.
Done in place even without the auto-upgrade marker: the marker guards
full-file overwrites, while leaving this change to a .new.py merge would
silently break dev mode for every customized pre-1.6 main.
Returns the migrated content if the module was (or would be) patched,
None if there was nothing to patch.
"""
content = path.read_text("UTF-8")
migrated = _migrate_devmode_in_main(content)
if migrated is None:
return None
migrated = ruff_format_content(migrated, path, mode="isort")
if dry:
print(f"✅ Would patch {path} (DEVMODE → FASTAPI_VUE)")
return migrated
path.write_text(migrated, "UTF-8", newline="\n")
print(f"✅ Patched {path} (DEVMODE → FASTAPI_VUE)")
return migrated
def _add_env_prefix_to_main(content: str) -> str: def _add_env_prefix_to_main(content: str) -> str:
"""Add FASTAPI_VUE environment prefix setup to an existing main module.""" """Add FASTAPI_VUE environment prefix setup to an existing main module."""
lines = content.splitlines() lines = content.splitlines()
@@ -600,10 +670,9 @@ def render_template(template: str, **kwargs: str) -> str:
def needs_app_migration(project_dir: Path) -> bool: def needs_app_migration(project_dir: Path) -> bool:
"""Check if the project was set up with fastapi-vue older than 1.6. """Check if the project was set up with fastapi-vue older than 1.6.
Those versions patched app.py with `from fastapi_vue import Frontend` and Those versions patched app.py with a DEVMODE import from the main module;
a DEVMODE import from the main module; 1.6+ uses fastapi_vue.Frontend and 1.6+ uses env.dev from fastapi_vue instead. Must be called before the
fastapi_vue.env. Must be called before the dependency step rewrites the dependency step rewrites the fastapi-vue requirement in pyproject.toml.
fastapi-vue requirement in pyproject.toml.
""" """
pyproject = project_dir / "pyproject.toml" pyproject = project_dir / "pyproject.toml"
if not pyproject.exists(): if not pyproject.exists():
@@ -624,8 +693,8 @@ def patch_app_file(
Inserts imports at top (ruff will sort them), route at bottom, Inserts imports at top (ruff will sort them), route at bottom,
and tries to patch lifespan with frontend.load(). With migrate=True, and tries to patch lifespan with frontend.load(). With migrate=True,
pre-1.6 patching (plain Frontend, DEVMODE import) is first rewritten pre-1.6 patching (DEVMODE import from the main module) is first
to the current format. rewritten to the current format (env.dev).
Returns True if patched, False if already patched or failed. Returns True if patched, False if already patched or failed.
""" """
@@ -636,17 +705,18 @@ def patch_app_file(
original_content = path.read_text("UTF-8") original_content = path.read_text("UTF-8")
content = original_content content = original_content
# Migrate pre-1.6 patching to the current format: Frontend via the # Migrate pre-1.6 patching to the current format: DEVMODE via
# fastapi_vue module, DEVMODE via fastapi_vue.env # fastapi_vue.env (the Frontend import stays as-is)
if migrate: if migrate:
if "from fastapi_vue import Frontend\n" in content:
content = content.replace("from fastapi_vue import Frontend\n", "")
content = re.sub(r"(?<![\w.])Frontend\(", "fastapi_vue.Frontend(", content)
old_import = f"from {main_module_path} import DEVMODE" old_import = f"from {main_module_path} import DEVMODE"
if old_import in content: if old_import in content:
has_plain_import = re.search(r"^import fastapi_vue$", content, re.MULTILINE) # The import sort at the end merges this with any existing
content = content.replace(old_import, "" if has_plain_import else "import fastapi_vue") # `from fastapi_vue import Frontend` line
content = content.replace("debug=DEVMODE", "debug=fastapi_vue.env.dev") content = content.replace(old_import, "from fastapi_vue import env")
# Replace every remaining standalone DEVMODE reference (not just
# the debug= parameter); it may also appear inside string literals,
# but that's an accepted risk over AST rewriting
content = re.sub(r"(?<![\w.])DEVMODE\b", "env.dev", content)
# Check what's already patched; plain "Frontend(" so user modifications # Check what's already patched; plain "Frontend(" so user modifications
# of the integration (renames, different call shape) still count # of the integration (renames, different call shape) still count
@@ -662,12 +732,14 @@ def patch_app_file(
route_line = f'frontend.route({app_var}, "/")' route_line = f'frontend.route({app_var}, "/")'
# Add missing imports (using AST to find correct insertion point); # Add missing imports (using AST to find correct insertion point);
# every patch path uses fastapi_vue.*, so always ensure the plain import # the import sort at the end merges duplicate from-imports
imports = [] imports = []
if not has_frontend: if not has_frontend:
imports.append("from pathlib import Path") imports.append("from pathlib import Path")
if not re.search(r"^import fastapi_vue$", content, re.MULTILINE): if not has_frontend or not re.search(
imports.append("import fastapi_vue") r"^from fastapi_vue import\b.*\benv\b", content, re.MULTILINE
):
imports.append("from fastapi_vue import Frontend, env")
if imports: if imports:
insert_line = find_import_insertion_line(content) insert_line = find_import_insertion_line(content)
lines = content.splitlines(keepends=True) lines = content.splitlines(keepends=True)
@@ -702,14 +774,14 @@ def patch_app_file(
lines.append(route_line) lines.append(route_line)
content = "\n".join(lines) content = "\n".join(lines)
# Try to patch FastAPI() call with debug=fastapi_vue.env.dev if no debug arg exists # Try to patch FastAPI() call with debug=env.dev if no debug arg exists
if not has_debug_arg: if not has_debug_arg:
fastapi_pattern = r"(\w+\s*=\s*FastAPI\s*\()([^)]*)\)" fastapi_pattern = r"(\w+\s*=\s*FastAPI\s*\()([^)]*)\)"
for match in re.finditer(fastapi_pattern, content, re.DOTALL): for match in re.finditer(fastapi_pattern, content, re.DOTALL):
args = match.group(2) args = match.group(2)
if "debug" not in args: if "debug" not in args:
# Add debug=fastapi_vue.env.dev as last argument # Add debug=env.dev as last argument
new_args = (f"{args}, " if args.strip() else "") + "debug=fastapi_vue.env.dev" new_args = (f"{args}, " if args.strip() else "") + "debug=env.dev"
content = ( content = (
content[: match.start()] content[: match.start()]
+ match.group(1) + match.group(1)
@@ -1008,6 +1080,11 @@ def _upgrade_old_vite_plugin(path: Path, module_name: str, *, dry: bool = False)
_new_files_written: list[tuple[Path, Path]] = [] _new_files_written: list[tuple[Path, Path]] = []
def _strip_upgrade_marker(content: str) -> str:
"""Remove the auto-upgrade marker line, for content comparison."""
return "\n".join(line for line in content.splitlines() if UPGRADE_MARKER not in line)
def write_file( def write_file(
path: Path, path: Path,
content: str, content: str,
@@ -1531,14 +1608,26 @@ def cmd_setup(args: argparse.Namespace) -> int:
main_content = render_template(template, **tpl_vars) main_content = render_template(template, **tpl_vars)
if main_file.exists(): if main_file.exists():
# File exists: update if it has the auto-upgrade marker, otherwise use fallback # Spot-patch the pre-1.6 DEVMODE mechanism in place first - the
write_file( # auto-upgrade marker guards full-file overwrites, but leaving this
main_file, # change to a .new.py merge would silently break dev mode
main_content, migrated = _patch_main_devmode(main_file, dry=dry)
overwrite=True, existing = migrated if migrated is not None else main_file.read_text("UTF-8")
dry=dry, # Update if it has the auto-upgrade marker, otherwise use fallback -
fallback_path=main_fallback, # unless the markerless file is otherwise up to date (e.g. the
) # DEVMODE spot-patch was the only change), then no fallback is needed
if UPGRADE_MARKER not in existing and _strip_upgrade_marker(
existing
) == _strip_upgrade_marker(ruff_format_content(main_content, main_file)):
print(f"✔️ {main_file} (already up to date)")
else:
write_file(
main_file,
main_content,
overwrite=True,
dry=dry,
fallback_path=main_fallback,
)
elif not existing_cli_module: elif not existing_cli_module:
# No file and no existing entrypoint: create new __main__.py # No file and no existing entrypoint: create new __main__.py
write_file( write_file(
@@ -1558,7 +1647,8 @@ def cmd_setup(args: argparse.Namespace) -> int:
executable=False, executable=False,
) )
if main.exists(): if main.exists():
content = main.read_text("UTF-8") migrated = _patch_main_devmode(main, dry=dry)
content = migrated if migrated is not None else main.read_text("UTF-8")
if "FASTAPI_VUE" not in content: if "FASTAPI_VUE" not in content:
new_content = _add_env_prefix_to_main(content) new_content = _add_env_prefix_to_main(content)
new_file = main.with_suffix(".new.py") new_file = main.with_suffix(".new.py")
@@ -1651,9 +1741,16 @@ def cmd_setup(args: argparse.Namespace) -> int:
fastapi_vue_req = f"fastapi-vue~={mmp[1]}.{mmp[2]}.{mmp[3]}" if mmp else "fastapi-vue" fastapi_vue_req = f"fastapi-vue~={mmp[1]}.{mmp[2]}.{mmp[3]}" if mmp else "fastapi-vue"
if dry: if dry:
print(f"📦 Would add: fastapi[standard], {fastapi_vue_req}") print(f"📦 Would add: fastapi[standard], {fastapi_vue_req}")
print("📦 Would run: uv sync")
else: else:
print("📦 Dependencies") print("📦 Dependencies")
uv_add_packages(["fastapi[standard]", fastapi_vue_req], cwd=project_dir) uv_add_packages(["fastapi[standard]", fastapi_vue_req], cwd=project_dir)
# uv add runs with --frozen, so lock and sync the environment once
# everything is in place; attached to the terminal so the user sees
# the updates, and non-fatal - setup is complete either way
result = subprocess.run(["uv", "sync"], cwd=project_dir, check=False) # noqa: S607
if result.returncode != 0:
print("⚠️ uv sync failed - run it manually to update the environment")
print() print()
print_boxed("Setup complete!") print_boxed("Setup complete!")
+2 -3
View File
@@ -5,8 +5,7 @@ import argparse
import os import os
from pathlib import Path from pathlib import Path
import fastapi_vue from fastapi_vue import env, server
from fastapi_vue import server
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
os.environ["FASTAPI_VUE"] = "ENVPREFIX" os.environ["FASTAPI_VUE"] = "ENVPREFIX"
@@ -27,7 +26,7 @@ def main() -> None:
listen=args.listen, listen=args.listen,
default_port=DEFAULT_PORT, default_port=DEFAULT_PORT,
server_header=False, server_header=False,
reload=Path(__file__).parent if fastapi_vue.env.dev else False, reload=Path(__file__).parent if env.dev else False,
) )
+3 -3
View File
@@ -4,11 +4,11 @@ from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
import fastapi_vue
from fastapi import FastAPI from fastapi import FastAPI
from fastapi_vue import Frontend, env
# Vue Frontend static files # Vue Frontend static files
frontend = fastapi_vue.Frontend(Path(__file__).with_name("frontend-build")) frontend = Frontend(Path(__file__).with_name("frontend-build"))
@asynccontextmanager @asynccontextmanager
@@ -18,7 +18,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator:
yield yield
app = FastAPI(title="PROJECT_TITLE", debug=fastapi_vue.env.dev, lifespan=lifespan) app = FastAPI(title="PROJECT_TITLE", debug=env.dev, lifespan=lifespan)
# Add API routes here... # Add API routes here...