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`). - 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.""" """FastAPI Vue integration - serve Vue frontend from FastAPI."""
from .environ import env
from .staticfiles import Frontend 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.main import STARTUP_FAILURE
from uvicorn.supervisors import ChangeReload, Multiprocess from uvicorn.supervisors import ChangeReload, Multiprocess
from .environ import env
from .hostutil import parse_endpoints from .hostutil import parse_endpoints
from .logging import ( from .logging import (
install_access_log, install_access_log,
@@ -44,10 +45,16 @@ def _bind_hosts(host: str) -> list[str]:
def _connect_url(endpoints: list[dict]) -> str: def _connect_url(endpoints: list[dict]) -> str:
"""Return a URL the user can connect to for the first TCP endpoint.""" """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: When running under the devserver (<PREFIX>_VITE_URL is set), the vite
return value 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: for endpoint in endpoints:
host = endpoint.get("host") host = endpoint.get("host")
if host is None: if host is None:
+4 -1
View File
@@ -19,6 +19,8 @@ from starlette.exceptions import HTTPException
from starlette.routing import Route from starlette.routing import Route
from zstandard import ZstdCompressor from zstandard import ZstdCompressor
from .environ import env
logger = logging.getLogger("uvicorn.error") # Use FastAPI logging style logger = logging.getLogger("uvicorn.error") # Use FastAPI logging style
__all__ = ["Frontend"] __all__ = ["Frontend"]
@@ -287,7 +289,8 @@ class Frontend:
def _devmode_respond(_request: Request, _name: str = "") -> JSONResponse: def _devmode_respond(_request: Request, _name: str = "") -> JSONResponse:
"""Return error response directing to Vite server.""" """Return error response directing to Vite server."""
at = f" at {env.vite_url}" if env.vite_url else ""
return JSONResponse( return JSONResponse(
status_code=409, 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 instantiation block for patching existing apps
FRONTEND_BLOCK = """ FRONTEND_BLOCK = """
# Vue Frontend static files # 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 # 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 return None
def _add_devmode_to_main(content: str) -> str: def _add_env_prefix_to_main(content: str) -> str:
"""Add DEVMODE variable to an existing main module.""" """Add FASTAPI_VUE environment prefix setup to an existing main module."""
lines = content.splitlines() lines = content.splitlines()
# Check if os is imported # Check if os is imported
@@ -489,7 +489,7 @@ def _add_devmode_to_main(content: str) -> str:
elif stripped and not stripped.startswith("#"): elif stripped and not stripped.startswith("#"):
break break
# Insert imports and DEVMODE after existing imports # Insert imports and env setup after existing imports
new_lines = [] new_lines = []
if not has_os_import: if not has_os_import:
new_lines.append("import os") new_lines.append("import os")
@@ -497,7 +497,7 @@ def _add_devmode_to_main(content: str) -> str:
[ [
"", "",
"# Added by fastapi-vue-setup", "# 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 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. """Patch an existing app.py with frontend integration.
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(). 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. 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") original_content = path.read_text("UTF-8")
content = original_content content = original_content
# Check what's already patched # Migrate pre-1.6 patching to the current format: Frontend via the
has_frontend = "from fastapi_vue import Frontend" in content # fastapi_vue module, DEVMODE via fastapi_vue.env
has_devmode = f"from {main_module_path} import DEVMODE" in content 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_debug_arg = re.search(r"FastAPI\s*\([^)]*debug\s*=", content) is not None
has_lifespan = "await frontend.load()" in content 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)") print(f"✔️ {path} (already patched)")
return False return False
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
imports = [] imports = []
if not has_frontend: if not has_frontend:
imports.extend(["from pathlib import Path", "from fastapi_vue import Frontend"]) imports.append("from pathlib import Path")
if not has_devmode: if not re.search(r"^import fastapi_vue$", content, re.MULTILINE):
imports.append(f"from {main_module_path} import DEVMODE") imports.append("import fastapi_vue")
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)
@@ -664,14 +702,14 @@ def patch_app_file(path: Path, main_module_path: str, app_var: str, *, dry: bool
lines.append(route_line) lines.append(route_line)
content = "\n".join(lines) 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: 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=DEVMODE as last argument # Add debug=fastapi_vue.env.dev as last argument
new_args = f"{args}, debug=DEVMODE" if args.strip() else "debug=DEVMODE" new_args = (f"{args}, " if args.strip() else "") + "debug=fastapi_vue.env.dev"
content = ( content = (
content[: match.start()] content[: match.start()]
+ match.group(1) + 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 # Check if project already has a CLI entrypoint in pyproject.toml
existing_cli_module = _find_existing_cli_module_path(project_dir, module_name) 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__" main_module_path = existing_cli_module or f"{module_name}.__main__"
if existing_cli_module: if existing_cli_module:
print(f"️ Using existing CLI: {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 === # === 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
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: else:
# No app: create full app.py # No app: create full app.py
# Create __init__.py if missing # Create __init__.py if missing
@@ -1509,7 +1549,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
) )
else: else:
# Existing CLI entrypoint: write our template as .new.py beside the existing module # 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( _write_fallback_file(
main, main,
main_fallback, main_fallback,
@@ -1519,8 +1559,8 @@ def cmd_setup(args: argparse.Namespace) -> int:
) )
if main.exists(): if main.exists():
content = main.read_text("UTF-8") content = main.read_text("UTF-8")
if "DEVMODE" not in content: if "FASTAPI_VUE" not in content:
new_content = _add_devmode_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")
_write_fallback_file( _write_fallback_file(
main, main,
+3 -2
View File
@@ -5,10 +5,11 @@ import argparse
import os import os
from pathlib import Path from pathlib import Path
import fastapi_vue
from fastapi_vue import server from fastapi_vue import server
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
DEVMODE = os.getenv("ENVPREFIX_DEV") == "1" os.environ["FASTAPI_VUE"] = "ENVPREFIX"
def main() -> None: def main() -> None:
@@ -26,7 +27,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 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 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
from MAIN_MODULE import DEVMODE
# Vue Frontend static files # Vue Frontend static files
frontend = Frontend(Path(__file__).with_name("frontend-build")) frontend = fastapi_vue.Frontend(Path(__file__).with_name("frontend-build"))
@asynccontextmanager @asynccontextmanager
@@ -19,7 +18,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator:
yield 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... # Add API routes here...