Compare commits

..
3 Commits
6 changed files with 139 additions and 48 deletions
+2
View File
@@ -129,3 +129,5 @@ The installed application instead depends on the lightweight [fastapi_vue](https
This keeps the development tooling where it belongs while leaving the distributed application as a normal, self-contained Python package.
️ The version numbering between the runtime and setup packages is synchronized, and the setup script always bumps the version in `pyproject.toml` to ensure compatible updates.
<img src="https://raw.githubusercontent.com/LeoVasanko/fastapi-vue-setup/main/docs/my-app.webp" alt="My App startup box and log items" width="500">
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+1 -8
View File
@@ -73,14 +73,7 @@ A single `reload` argument replaces Uvicorn's separate reload arguments and may
A startup box with the app name, version and connect URL is printed before serving. Pass a `startup_box` template (`{name}`, `{version}`, `{listen}`, `{url}`, ...) to customize it, None to disable, or use `server.print_startup_box` on its own.
Printed by `server.run("my_app.app:app", listen=["localhost:3100"])`:
```
╭──────────────────────────────────────────╮
│ My App 0.1.0 @ 127.0.0.1:3100 [::1]:3100 │
│ http://localhost:3100 │
╰──────────────────────────────────────────╯
```
<img src="https://raw.githubusercontent.com/LeoVasanko/fastapi-vue-setup/main/docs/my-app.webp" alt="My App startup box and log items" width="500">
Logging is integrated as well: removes noisy uvicorn logging, replacing it with prettified log formatting, a colored access log and tracebacks rendered by [tracerite](https://pypi.org/project/tracerite/). Note that HTTP responses also include tracerite formatting when `FastAPI(debug=True)` is used.
+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:
"""Add packages using uv."""
cmd = ["uv", "add", "-q", "-U"]
"""Add packages using uv.
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:
cmd.extend(["--group", group])
else:
cmd.append("--no-sync")
cmd.extend(packages)
result = subprocess.run(cmd, cwd=cwd, check=False) # noqa: S603
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_BLOCK = """
# 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
@@ -473,6 +475,74 @@ def _find_app_in_subpackage(subpkg_dir: Path) -> tuple[Path, str] | 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:
"""Add FASTAPI_VUE environment prefix setup to an existing main module."""
lines = content.splitlines()
@@ -600,10 +670,9 @@ def render_template(template: str, **kwargs: str) -> str:
def needs_app_migration(project_dir: Path) -> bool:
"""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
a DEVMODE import from the main module; 1.6+ uses fastapi_vue.Frontend and
fastapi_vue.env. Must be called before the dependency step rewrites the
fastapi-vue requirement in pyproject.toml.
Those versions patched app.py with a DEVMODE import from the main module;
1.6+ uses env.dev from fastapi_vue instead. Must be called before the
dependency step rewrites the fastapi-vue requirement in pyproject.toml.
"""
pyproject = project_dir / "pyproject.toml"
if not pyproject.exists():
@@ -624,8 +693,8 @@ def patch_app_file(
Inserts imports at top (ruff will sort them), route at bottom,
and tries to patch lifespan with frontend.load(). With migrate=True,
pre-1.6 patching (plain Frontend, DEVMODE import) is first rewritten
to the current format.
pre-1.6 patching (DEVMODE import from the main module) is first
rewritten to the current format (env.dev).
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")
content = original_content
# Migrate pre-1.6 patching to the current format: Frontend via the
# fastapi_vue module, DEVMODE via fastapi_vue.env
# Migrate pre-1.6 patching to the current format: DEVMODE via
# fastapi_vue.env (the Frontend import stays as-is)
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"
if old_import in content:
has_plain_import = re.search(r"^import fastapi_vue$", content, re.MULTILINE)
content = content.replace(old_import, "" if has_plain_import else "import fastapi_vue")
content = content.replace("debug=DEVMODE", "debug=fastapi_vue.env.dev")
# The import sort at the end merges this with any existing
# `from fastapi_vue import Frontend` line
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
# of the integration (renames, different call shape) still count
@@ -662,12 +732,14 @@ def patch_app_file(
route_line = f'frontend.route({app_var}, "/")'
# 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 = []
if not has_frontend:
imports.append("from pathlib import Path")
if not re.search(r"^import fastapi_vue$", content, re.MULTILINE):
imports.append("import fastapi_vue")
if not has_frontend or not re.search(
r"^from fastapi_vue import\b.*\benv\b", content, re.MULTILINE
):
imports.append("from fastapi_vue import Frontend, env")
if imports:
insert_line = find_import_insertion_line(content)
lines = content.splitlines(keepends=True)
@@ -702,14 +774,14 @@ def patch_app_file(
lines.append(route_line)
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:
fastapi_pattern = r"(\w+\s*=\s*FastAPI\s*\()([^)]*)\)"
for match in re.finditer(fastapi_pattern, content, re.DOTALL):
args = match.group(2)
if "debug" not in args:
# Add debug=fastapi_vue.env.dev as last argument
new_args = (f"{args}, " if args.strip() else "") + "debug=fastapi_vue.env.dev"
# Add debug=env.dev as last argument
new_args = (f"{args}, " if args.strip() else "") + "debug=env.dev"
content = (
content[: match.start()]
+ 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]] = []
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(
path: Path,
content: str,
@@ -1531,14 +1608,26 @@ def cmd_setup(args: argparse.Namespace) -> int:
main_content = render_template(template, **tpl_vars)
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=dry,
fallback_path=main_fallback,
)
# Spot-patch the pre-1.6 DEVMODE mechanism in place first - the
# auto-upgrade marker guards full-file overwrites, but leaving this
# change to a .new.py merge would silently break dev mode
migrated = _patch_main_devmode(main_file, dry=dry)
existing = migrated if migrated is not None else main_file.read_text("UTF-8")
# Update if it has the auto-upgrade marker, otherwise use 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:
# No file and no existing entrypoint: create new __main__.py
write_file(
@@ -1558,7 +1647,8 @@ def cmd_setup(args: argparse.Namespace) -> int:
executable=False,
)
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:
new_content = _add_env_prefix_to_main(content)
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"
if dry:
print(f"📦 Would add: fastapi[standard], {fastapi_vue_req}")
print("📦 Would run: uv sync")
else:
print("📦 Dependencies")
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_boxed("Setup complete!")
+2 -3
View File
@@ -5,8 +5,7 @@ import argparse
import os
from pathlib import Path
import fastapi_vue
from fastapi_vue import server
from fastapi_vue import env, server
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
os.environ["FASTAPI_VUE"] = "ENVPREFIX"
@@ -27,7 +26,7 @@ def main() -> None:
listen=args.listen,
default_port=DEFAULT_PORT,
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 pathlib import Path
import fastapi_vue
from fastapi import FastAPI
from fastapi_vue import Frontend, env
# Vue Frontend static files
frontend = fastapi_vue.Frontend(Path(__file__).with_name("frontend-build"))
frontend = Frontend(Path(__file__).with_name("frontend-build"))
@asynccontextmanager
@@ -18,7 +18,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator:
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...