Add fastapi_vue.env accessor with FASTAPI_VUE prefix

This commit is contained in:
2026-09-13 21:24:51 +00:00
parent baab37ae2d
commit dec5191157
8 changed files with 142 additions and 35 deletions
+10
View File
@@ -68,3 +68,13 @@ server.run("my_app.app:app", listen=["localhost:8000"])
```
- As a deployment option, environment `FORWARDED_ALLOW_IPS` controls `X-Forwarded` trusted IPs (default: `127.0.0.1,::1`).
## Environment variables
The generated project entry point sets `FASTAPI_VUE` to the project's environment prefix (e.g. `MY_APP`), and settings are passed as `<PREFIX>_*` variables. `fastapi_vue.env` resolves them, lazily on each access:
- `fastapi_vue.env.dev` — running under the devserver (`<PREFIX>_DEV=1`)
- `fastapi_vue.env.vite_url`, `fastapi_vue.env.backend_url` — URLs set by the devserver
- `fastapi_vue.env.prefix` — the prefix itself
Value accessors return `None` when `FASTAPI_VUE` or the variable is not set.
+2 -1
View File
@@ -1,5 +1,6 @@
"""FastAPI Vue integration - serve Vue frontend from FastAPI."""
from .environ import env
from .staticfiles import Frontend
__all__ = ["Frontend"]
__all__ = ["Frontend", "env"]
+46
View File
@@ -0,0 +1,46 @@
"""Access to the project's fastapi-vue environment variables.
The project entry point (generated __main__.py) sets FASTAPI_VUE to the
project-specific prefix (e.g. "MY_APP"). Project settings are then passed
as "<PREFIX>_*" environment variables; this module is the single place
that resolves those names.
"""
import os
PREFIX_VARIABLE = "FASTAPI_VUE"
class _Env:
"""Lazy accessors for the project's "<PREFIX>_*" environment variables.
Evaluated on each access. Value accessors return None when FASTAPI_VUE
or the variable itself is not set.
"""
@property
def prefix(self) -> str | None:
"""Return the project prefix from the FASTAPI_VUE environment variable."""
return os.environ.get(PREFIX_VARIABLE) or None
def _get(self, name: str) -> str | None:
prefix = self.prefix
return os.environ.get(f"{prefix}_{name}") if prefix else None
@property
def dev(self) -> bool:
"""Check whether running under the devserver (<PREFIX>_DEV=1)."""
return self._get("DEV") == "1"
@property
def vite_url(self) -> str | None:
"""Return the vite devserver URL (<PREFIX>_VITE_URL), if set."""
return self._get("VITE_URL")
@property
def backend_url(self) -> str | None:
"""Return the backend URL (<PREFIX>_BACKEND_URL), if set."""
return self._get("BACKEND_URL")
env = _Env()
+11 -4
View File
@@ -15,6 +15,7 @@ from uvicorn import Config, Server
from uvicorn.main import STARTUP_FAILURE
from uvicorn.supervisors import ChangeReload, Multiprocess
from .environ import env
from .hostutil import parse_endpoints
from .logging import (
install_access_log,
@@ -44,10 +45,16 @@ def _bind_hosts(host: str) -> list[str]:
def _connect_url(endpoints: list[dict]) -> str:
"""Return a URL the user can connect to for the first TCP endpoint."""
for key, value in sorted(os.environ.items()):
if key.endswith("_VITE_URL") and value:
return value
"""Return a URL the user can connect to for the first TCP endpoint.
When running under the devserver (<PREFIX>_VITE_URL is set), the vite
devserver URL is shown instead, as that is where the page is served.
Wildcard binds (0.0.0.0, ::) are shown as localhost, as that is the
address a user can actually open. Unix-socket-only setups show plain
http://localhost (the typical reverse-proxy target).
"""
if vite_url := env.vite_url:
return vite_url
for endpoint in endpoints:
host = endpoint.get("host")
if host is None:
+4 -1
View File
@@ -19,6 +19,8 @@ from starlette.exceptions import HTTPException
from starlette.routing import Route
from zstandard import ZstdCompressor
from .environ import env
logger = logging.getLogger("uvicorn.error") # Use FastAPI logging style
__all__ = ["Frontend"]
@@ -287,7 +289,8 @@ class Frontend:
def _devmode_respond(_request: Request, _name: str = "") -> JSONResponse:
"""Return error response directing to Vite server."""
at = f" at {env.vite_url}" if env.vite_url else ""
return JSONResponse(
status_code=409,
content={"detail": "[devmode] Use Vite devserver instead."},
content={"detail": f"[devmode] Use Vite devserver{at} instead."},
)
+63 -23
View File
@@ -161,7 +161,7 @@ NEW_BUILD_HOOK_PATH = "scripts/fastapi-vue/buildhook.py"
# Frontend instantiation block for patching existing apps
FRONTEND_BLOCK = """
# Vue Frontend static files
frontend = Frontend(Path(__file__).with_name("frontend-build"))
frontend = fastapi_vue.Frontend(Path(__file__).with_name("frontend-build"))
"""
# Lifespan block for patching apps that don't have one
@@ -473,8 +473,8 @@ def _find_app_in_subpackage(subpkg_dir: Path) -> tuple[Path, str] | None:
return None
def _add_devmode_to_main(content: str) -> str:
"""Add DEVMODE variable to an existing main module."""
def _add_env_prefix_to_main(content: str) -> str:
"""Add FASTAPI_VUE environment prefix setup to an existing main module."""
lines = content.splitlines()
# Check if os is imported
@@ -489,7 +489,7 @@ def _add_devmode_to_main(content: str) -> str:
elif stripped and not stripped.startswith("#"):
break
# Insert imports and DEVMODE after existing imports
# Insert imports and env setup after existing imports
new_lines = []
if not has_os_import:
new_lines.append("import os")
@@ -497,7 +497,7 @@ def _add_devmode_to_main(content: str) -> str:
[
"",
"# Added by fastapi-vue-setup",
'DEVMODE = os.getenv("ENVPREFIX_DEV") == "1"',
'os.environ["FASTAPI_VUE"] = "ENVPREFIX"',
"",
]
)
@@ -597,11 +597,35 @@ def render_template(template: str, **kwargs: str) -> str:
return result
def patch_app_file(path: Path, main_module_path: str, app_var: str, *, dry: bool = False) -> bool:
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.
"""
pyproject = project_dir / "pyproject.toml"
if not pyproject.exists():
return False
data = tomlkit.parse(pyproject.read_text("UTF-8"))
for dep in data.get("project", {}).get("dependencies", []):
match = re.match(r"\s*fastapi-vue(?:\[[^\]]*\])?\s*(.*)", str(dep))
if match:
version = re.search(r"(\d+)\.(\d+)", match.group(1))
return version is not None and (int(version[1]), int(version[2])) < (1, 6)
return False
def patch_app_file(
path: Path, main_module_path: str, app_var: str, *, migrate: bool = False, dry: bool = False
) -> bool:
"""Patch an existing app.py with frontend integration.
Inserts imports at top (ruff will sort them), route at bottom,
and tries to patch lifespan with frontend.load().
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.
Returns True if patched, False if already patched or failed.
"""
@@ -612,24 +636,38 @@ def patch_app_file(path: Path, main_module_path: str, app_var: str, *, dry: bool
original_content = path.read_text("UTF-8")
content = original_content
# Check what's already patched
has_frontend = "from fastapi_vue import Frontend" in content
has_devmode = f"from {main_module_path} import DEVMODE" in content
# Migrate pre-1.6 patching to the current format: Frontend via the
# fastapi_vue module, DEVMODE via fastapi_vue.env
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")
# Check what's already patched; plain "Frontend(" so user modifications
# of the integration (renames, different call shape) still count
has_frontend = "Frontend(" in content
has_debug_arg = re.search(r"FastAPI\s*\([^)]*debug\s*=", content) is not None
has_lifespan = "await frontend.load()" in content
if has_frontend and has_devmode and has_debug_arg and has_lifespan:
already_patched = has_frontend and has_debug_arg and has_lifespan
if content == original_content and already_patched:
print(f"✔️ {path} (already patched)")
return False
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
imports = []
if not has_frontend:
imports.extend(["from pathlib import Path", "from fastapi_vue import Frontend"])
if not has_devmode:
imports.append(f"from {main_module_path} import DEVMODE")
imports.append("from pathlib import Path")
if not re.search(r"^import fastapi_vue$", content, re.MULTILINE):
imports.append("import fastapi_vue")
if imports:
insert_line = find_import_insertion_line(content)
lines = content.splitlines(keepends=True)
@@ -664,14 +702,14 @@ def patch_app_file(path: Path, main_module_path: str, app_var: str, *, dry: bool
lines.append(route_line)
content = "\n".join(lines)
# Try to patch FastAPI() call with debug=DEVMODE if no debug arg exists
# Try to patch FastAPI() call with debug=fastapi_vue.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=DEVMODE as last argument
new_args = f"{args}, debug=DEVMODE" if args.strip() else "debug=DEVMODE"
# Add debug=fastapi_vue.env.dev as last argument
new_args = (f"{args}, " if args.strip() else "") + "debug=fastapi_vue.env.dev"
content = (
content[: match.start()]
+ match.group(1)
@@ -1316,7 +1354,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
# 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
# Determine main module path (for migrating old DEVMODE imports)
main_module_path = existing_cli_module or f"{module_name}.__main__"
if existing_cli_module:
print(f"️ Using existing CLI: {existing_cli_module}")
@@ -1463,7 +1501,9 @@ 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, main_module_path, app_var, dry=dry)
patch_app_file(
app_file, main_module_path, app_var, migrate=needs_app_migration(project_dir), dry=dry
)
else:
# No app: create full app.py
# Create __init__.py if missing
@@ -1509,7 +1549,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
)
else:
# Existing CLI entrypoint: write our template as .new.py beside the existing module
# and also patch the existing module with DEVMODE if needed
# and also patch the existing module with FASTAPI_VUE setup if needed
_write_fallback_file(
main,
main_fallback,
@@ -1519,8 +1559,8 @@ def cmd_setup(args: argparse.Namespace) -> int:
)
if main.exists():
content = main.read_text("UTF-8")
if "DEVMODE" not in content:
new_content = _add_devmode_to_main(content)
if "FASTAPI_VUE" not in content:
new_content = _add_env_prefix_to_main(content)
new_file = main.with_suffix(".new.py")
_write_fallback_file(
main,
+3 -2
View File
@@ -5,10 +5,11 @@ import argparse
import os
from pathlib import Path
import fastapi_vue
from fastapi_vue import server
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
DEVMODE = os.getenv("ENVPREFIX_DEV") == "1"
os.environ["FASTAPI_VUE"] = "ENVPREFIX"
def main() -> None:
@@ -26,7 +27,7 @@ def main() -> None:
listen=args.listen,
default_port=DEFAULT_PORT,
server_header=False,
reload=Path(__file__).parent if DEVMODE else False,
reload=Path(__file__).parent if fastapi_vue.env.dev else False,
)
+3 -4
View File
@@ -4,12 +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
from MAIN_MODULE import DEVMODE
# Vue Frontend static files
frontend = Frontend(Path(__file__).with_name("frontend-build"))
frontend = fastapi_vue.Frontend(Path(__file__).with_name("frontend-build"))
@asynccontextmanager
@@ -19,7 +18,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator:
yield
app = FastAPI(title="PROJECT_TITLE", debug=DEVMODE, lifespan=lifespan)
app = FastAPI(title="PROJECT_TITLE", debug=fastapi_vue.env.dev, lifespan=lifespan)
# Add API routes here...