From dec51911571bc5f3d8c90eedf4d5d4448a2b3cb4 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 13 Sep 2026 21:24:51 +0000 Subject: [PATCH] Add fastapi_vue.env accessor with FASTAPI_VUE prefix --- fastapi-vue/README.md | 10 +++ fastapi-vue/fastapi_vue/__init__.py | 3 +- fastapi-vue/fastapi_vue/environ.py | 46 ++++++++++++++ fastapi-vue/fastapi_vue/server.py | 15 +++-- fastapi-vue/fastapi_vue/staticfiles.py | 5 +- fastapi_vue_setup.py | 86 +++++++++++++++++++------- template/backend/__main__.py | 5 +- template/backend/app.py | 7 +-- 8 files changed, 142 insertions(+), 35 deletions(-) create mode 100644 fastapi-vue/fastapi_vue/environ.py diff --git a/fastapi-vue/README.md b/fastapi-vue/README.md index 818900b..3013b1e 100644 --- a/fastapi-vue/README.md +++ b/fastapi-vue/README.md @@ -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 `_*` variables. `fastapi_vue.env` resolves them, lazily on each access: + +- `fastapi_vue.env.dev` — running under the devserver (`_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. diff --git a/fastapi-vue/fastapi_vue/__init__.py b/fastapi-vue/fastapi_vue/__init__.py index fbbb0e5..8337afc 100644 --- a/fastapi-vue/fastapi_vue/__init__.py +++ b/fastapi-vue/fastapi_vue/__init__.py @@ -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"] diff --git a/fastapi-vue/fastapi_vue/environ.py b/fastapi-vue/fastapi_vue/environ.py new file mode 100644 index 0000000..3a3cdd9 --- /dev/null +++ b/fastapi-vue/fastapi_vue/environ.py @@ -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 "_*" 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 "_*" 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 (_DEV=1).""" + return self._get("DEV") == "1" + + @property + def vite_url(self) -> str | None: + """Return the vite devserver URL (_VITE_URL), if set.""" + return self._get("VITE_URL") + + @property + def backend_url(self) -> str | None: + """Return the backend URL (_BACKEND_URL), if set.""" + return self._get("BACKEND_URL") + + +env = _Env() diff --git a/fastapi-vue/fastapi_vue/server.py b/fastapi-vue/fastapi_vue/server.py index d8cd7a1..ebe0baf 100644 --- a/fastapi-vue/fastapi_vue/server.py +++ b/fastapi-vue/fastapi_vue/server.py @@ -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 (_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: diff --git a/fastapi-vue/fastapi_vue/staticfiles.py b/fastapi-vue/fastapi_vue/staticfiles.py index a6795e1..af5d8ef 100644 --- a/fastapi-vue/fastapi_vue/staticfiles.py +++ b/fastapi-vue/fastapi_vue/staticfiles.py @@ -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."}, ) diff --git a/fastapi_vue_setup.py b/fastapi_vue_setup.py index 218e861..ccc2611 100644 --- a/fastapi_vue_setup.py +++ b/fastapi_vue_setup.py @@ -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"(? 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, diff --git a/template/backend/__main__.py b/template/backend/__main__.py index 3dc1061..31f928b 100644 --- a/template/backend/__main__.py +++ b/template/backend/__main__.py @@ -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, ) diff --git a/template/backend/app.py b/template/backend/app.py index f14b493..ac1a6df 100644 --- a/template/backend/app.py +++ b/template/backend/app.py @@ -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...