From 4cd8dbfc73313b8edeec604d6eeac16be7fa23d1 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 15 Sep 2026 03:37:38 +0000 Subject: [PATCH] Update fastapi-vue-setup 1.6.1 --- frontend/vite-plugin-fastapi.js | 10 +- frontend/vite.config.js | 2 +- pagerite/__main__.py | 6 +- pagerite/app.py | 5 +- pagerite/state.py | 4 +- pagerite/views.py | 13 +-- pyproject.toml | 2 +- scripts/devserver.py | 15 ++- scripts/fastapi-vue/buildhook.py | 1 - scripts/fastapi-vue/buildutil.py | 5 +- scripts/fastapi-vue/devutil.py | 174 ++++++++++++++----------------- 11 files changed, 110 insertions(+), 127 deletions(-) diff --git a/frontend/vite-plugin-fastapi.js b/frontend/vite-plugin-fastapi.js index dbf41d8..f5dfdb5 100644 --- a/frontend/vite-plugin-fastapi.js +++ b/frontend/vite-plugin-fastapi.js @@ -8,11 +8,11 @@ * - Disables Vite's screen clearing on startup * * Options: - * paths - Array of paths to proxy (default: ["/api"]) + * paths - Array of paths to proxy (default: ['/api']) */ -export default function fastapiVue({ paths = ["/api"] } = {}) { - const backendUrl = process.env.PAGERITE_BACKEND_URL || "http://localhost:8210" +export default function fastapiVue({ paths = ['/api'] } = {}) { + const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:8210' // Build proxy configuration for each path const proxy = {} @@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) { } return { - name: "vite-plugin-fastapi-pagerite", + name: 'vite-plugin-fastapi-pagerite', config: () => ({ clearScreen: false, server: { proxy }, build: { - outDir: "../pagerite/frontend-build", + outDir: '../pagerite/frontend-build', emptyOutDir: true, }, }), diff --git a/frontend/vite.config.js b/frontend/vite.config.js index fc742ec..2ecf691 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -5,7 +5,7 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import vueDevTools from 'vite-plugin-vue-devtools' -const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200' +const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:8210' // Proxy everything except Vite's own dev-time paths and the backend machinery // to the FastAPI backend in dev. /_api, /_f, /_themes, /_fonts and /_a are diff --git a/pagerite/__main__.py b/pagerite/__main__.py index b11d6ed..f6d37a5 100644 --- a/pagerite/__main__.py +++ b/pagerite/__main__.py @@ -5,12 +5,12 @@ import os from pathlib import Path import msgspec -from fastapi_vue import server +from fastapi_vue import env, server from pagerite.config import Config DEFAULT_PORT = 8100 -DEVMODE = os.getenv("PAGERITE_DEV") == "1" +os.environ["FASTAPI_VUE"] = "PAGERITE" def main() -> None: @@ -54,7 +54,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 env.dev else False, **run_args, ) diff --git a/pagerite/app.py b/pagerite/app.py index 8ca2379..f4d39e9 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -36,11 +36,10 @@ from pathlib import Path from fastapi import FastAPI, Request from fastapi.responses import Response -from fastapi_vue import Frontend +from fastapi_vue import Frontend, env from starlette.types import ASGIApp, Receive, Scope, Send from pagerite import api, files, pages, tracking -from pagerite.__main__ import DEVMODE from pagerite.files import file_store from pagerite.state import analytics_store, config, kanta @@ -99,7 +98,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator: # is not meant to be browsable by the public anyway. app = FastAPI( title="Pagerite", - debug=DEVMODE, + debug=env.dev, lifespan=lifespan, docs_url=None, redoc_url=None, diff --git a/pagerite/state.py b/pagerite/state.py index b109435..9ed847f 100644 --- a/pagerite/state.py +++ b/pagerite/state.py @@ -22,11 +22,11 @@ from pathlib import Path import blake3 from fastapi import HTTPException, Request from fastapi.responses import Response +from fastapi_vue import env from kanta import Kanta from zstandard import ZstdCompressor from pagerite import analytics, i18n, seed, translate, views -from pagerite.__main__ import DEVMODE from pagerite.chunks import store_chunks from pagerite.config import load from pagerite.data import ( @@ -230,7 +230,7 @@ def _html_response( # Absolute social/canonical URLs use the site's public origin; on # localhost (varying ports) fall back to the request's own base URL. base_url = SITE_URL or str(request.base_url).rstrip("/") - if DEVMODE: + if env.dev: identity = _render_html(kind, path, base_url, lang, link_lang).encode() body = _zstd.compress(identity) if zstd else identity else: diff --git a/pagerite/views.py b/pagerite/views.py index 4e8475b..68d6fda 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -21,6 +21,7 @@ import json import os import re +from fastapi_vue import env from html5tagger import HTML, Document, E, Template from platformdirs import site_data_dir, user_data_path @@ -337,7 +338,7 @@ def _layout( ) -> Template: """Page layout template with standard assets and ES-module scripts. - In dev (PAGERITE_VITE_URL set) assets are linked from the Vite dev + In dev (Vite dev-server URL set) assets are linked from the Vite dev server and stylesheets use ``blocking="render"`` so the browser waits for them before showing the page, avoiding a flash of unstyled content. In production all page assets are inlined into the document: stylesheets @@ -394,7 +395,7 @@ def _layout( # dev-server URLs as meta tags (Vite serves the modules and injects # their CSS for hot reloads); production inlines all page assets and # carries the on-demand URLs in one JSON script instead. - vite_url = os.environ.get("PAGERITE_VITE_URL") + vite_url = env.vite_url editor_scripts, editor_css = _editor_assets() langselect_scripts, langselect_css = _langselect_assets() config = { @@ -1226,7 +1227,7 @@ def _page_assets() -> tuple[list[str], list[str]]: by the entry (e.g. overlayscrollbars.css) is extracted by Vite and must be linked separately. """ - vite_url = os.environ.get("PAGERITE_VITE_URL") + vite_url = env.vite_url if vite_url: return [f"{vite_url}/src/pagerite.js"], [] if "page" not in _asset_cache: @@ -1244,7 +1245,7 @@ def _editor_assets() -> tuple[list[str], str | None]: The shared CSS is already linked on the page, so the pen only needs the editor-specific stylesheet. """ - vite_url = os.environ.get("PAGERITE_VITE_URL") + vite_url = env.vite_url if vite_url: return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None if "editor" not in _asset_cache: @@ -1256,7 +1257,7 @@ def _editor_assets() -> tuple[list[str], str | None]: def _analytics_assets() -> tuple[list[str], list[str]]: """Script and stylesheet URLs for the analytics page entry.""" - vite_url = os.environ.get("PAGERITE_VITE_URL") + vite_url = env.vite_url if vite_url: return [f"{vite_url}/src/analytics-main.js"], [] if "analytics" not in _asset_cache: @@ -1270,7 +1271,7 @@ def _analytics_assets() -> tuple[list[str], list[str]]: def _langselect_assets() -> tuple[list[str], list[str]]: """Script and stylesheet URLs for the on-demand public language selector.""" - vite_url = os.environ.get("PAGERITE_VITE_URL") + vite_url = env.vite_url if vite_url: return [f"{vite_url}/src/langselect-main.js"], [] if "langselect" not in _asset_cache: diff --git a/pyproject.toml b/pyproject.toml index 0796c98..c1656a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ readme = "README.md" requires-python = ">=3.14" dependencies = [ "blake3>=1.0.9", - "fastapi-vue~=1.4.2", + "fastapi-vue~=1.6.1", "fastapi[standard]>=0.141.1", "html5tagger>=2.0.0", "httpx>=0.28.1", diff --git a/scripts/devserver.py b/scripts/devserver.py index cedea45..f4bb6fc 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -1,11 +1,12 @@ #!/usr/bin/env -S uv run +# auto-upgrade@fastapi-vue-setup - remove this if you modify this file """Run Vite development server for Vue app and FastAPI backend with auto-reload.""" import argparse import asyncio import os +import subprocess import sys -from contextlib import suppress from pathlib import Path import tracerite @@ -47,11 +48,11 @@ async def run_devserver( os.environ["PAGERITE_DEV"] = "1" async with ProcessGroup() as pg: + pg.create_task(check_ports_free(viteurl, backurl)) npm_i = await pg.spawn(*npm_install, cwd=front) - await check_ports_free(viteurl, backurl) - await pg.spawn(*pagerite, *(extra_args or [])) + await pg.spawn(*pagerite, *(extra_args or []), vital=True) await pg.wait(npm_i, ready(backurl, path=HEALTH)) - await pg.spawn(*vite, cwd=front) + await pg.spawn(*vite, cwd=front, vital=True) def main() -> None: @@ -74,8 +75,12 @@ def main() -> None: help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})", ) args, extra_args = parser.parse_known_args() - with suppress(KeyboardInterrupt): + try: asyncio.run(run_devserver(args.listen, args.backend, extra_args)) + except* KeyboardInterrupt: + pass # user stopped the devserver: normal exit + except* subprocess.SubprocessError, RuntimeError: + raise SystemExit(1) from None # logged in devutil already; exit 1 HELP_EPILOG = """ diff --git a/scripts/fastapi-vue/buildhook.py b/scripts/fastapi-vue/buildhook.py index 407e4bf..db91983 100644 --- a/scripts/fastapi-vue/buildhook.py +++ b/scripts/fastapi-vue/buildhook.py @@ -1,4 +1,3 @@ -# ruff: noqa: INP001 """Hatch build hook for building Vue frontend during package build.""" import sys diff --git a/scripts/fastapi-vue/buildutil.py b/scripts/fastapi-vue/buildutil.py index 3150423..9f6875b 100644 --- a/scripts/fastapi-vue/buildutil.py +++ b/scripts/fastapi-vue/buildutil.py @@ -1,4 +1,3 @@ -# ruff: noqa: INP001 """Utilities used at build time and in devserver script. No dependencies.""" import logging @@ -33,7 +32,7 @@ def _check_node_version(node_path: str) -> None: Raises RuntimeError if version is too old or cannot be determined. """ try: - result = subprocess.run( # noqa: S603 + result = subprocess.run( [node_path, "--version"], capture_output=True, text=True, @@ -221,7 +220,7 @@ def build(folder: str = "frontend") -> None: def run(cmd: list[str]) -> None: display_cmd = [Path(cmd[0]).stem, *cmd[1:]] logger.info("### %s", " ".join(display_cmd)) - subprocess.run(cmd, check=True, cwd=folder) # noqa: S603 + subprocess.run(cmd, check=True, cwd=folder) try: run(install_cmd) diff --git a/scripts/fastapi-vue/devutil.py b/scripts/fastapi-vue/devutil.py index 80a28dc..bcfe0b8 100644 --- a/scripts/fastapi-vue/devutil.py +++ b/scripts/fastapi-vue/devutil.py @@ -1,111 +1,90 @@ -# ruff: noqa: INP001 """Utilities meant for devserver script, used only in source repository with dev deps.""" +from __future__ import annotations + import asyncio -import subprocess import sys +from asyncio.subprocess import Process from contextlib import suppress from pathlib import Path -from typing import TYPE_CHECKING, Any, Self +from subprocess import CalledProcessError +from typing import TYPE_CHECKING, Any from urllib.parse import urlsplit -from buildutil import find_dev_tool, find_install_tool, logger from fastapi_vue.hostutil import parse_endpoint +from buildutil import find_dev_tool, find_install_tool, logger + if TYPE_CHECKING: - from collections.abc import Coroutine + from collections.abc import Awaitable -class ProcessGroup: - """Manage async subprocesses with automatic cleanup, like TaskGroup for processes.""" +class ProcessGroup(asyncio.TaskGroup): + """TaskGroup with structured ownership of async subprocesses.""" - def __init__(self) -> None: - """Initialize empty process tracking.""" - self._procs: list[asyncio.subprocess.Process] = [] - self._cmds: dict[int, str] = {} # pid -> command name + def __init__(self, *, terminate_timeout: float = 10) -> None: + """Set the grace period before terminate() escalates to kill().""" + super().__init__() + self._terminate_timeout = terminate_timeout + self._cmds: dict[Process, tuple[str, ...]] = {} async def spawn( - self, - *cmd: str, - cwd: str | None = None, - ) -> asyncio.subprocess.Process: - """Spawn a subprocess and track it.""" - cmd_name = Path(cmd[0]).stem - logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]])) - proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd) - self._procs.append(proc) - self._cmds[proc.pid] = cmd_name - return proc + self, *cmd: str, cwd: str | None = None, vital: bool = False + ) -> Process: + """Spawn and own a subprocess. If a vital process exits, the group cancels.""" - async def wait( - self, - *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]", - ) -> None: - """Wait for processes/coroutines to complete, raise SystemExit on failure.""" + async def run() -> None: + name = Path(cmd[0]).stem + logger.info(">>> %s", " ".join([name, *cmd[1:]])) + try: + proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd) + self._cmds[proc] = cmd + started.set_result(proc) + except Exception as e: # noqa: BLE001 + started.set_exception(e) + return - async def wait_proc(proc: asyncio.subprocess.Process) -> None: - returncode = await proc.wait() - if returncode != 0: - cmd_name = self._cmds.get(proc.pid, "unknown") - raise subprocess.CalledProcessError(returncode, cmd_name) - - tasks = [ - wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w - for w in waitables - ] - try: - await asyncio.gather(*tasks) - except subprocess.CalledProcessError as e: - logger.warning("%s failed with exit status %d", e.cmd, e.returncode) - raise SystemExit(1) from None - - async def __aenter__(self) -> Self: - """Enter the async context manager.""" - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None: - """Wait for one process to exit, terminate others, then wait for all.""" - await self._cleanup(immediate=exc_type is not None) - - async def _cleanup(self, *, immediate: bool = False) -> None: - running = [p for p in self._procs if p.returncode is None] - if not running: - return - - if not immediate: - # Wait for any one process to exit - with suppress(asyncio.CancelledError): - await asyncio.wait( - [asyncio.create_task(p.wait()) for p in running], - return_when=asyncio.FIRST_COMPLETED, - ) - - # Terminate remaining processes - for p in self._procs: - if p.returncode is None: + try: + returncode = await proc.wait() + finally: with suppress(ProcessLookupError): - p.terminate() - - # Wait for all to finish (with overall timeout), shielded from cancellation - still_running = [p for p in self._procs if p.returncode is None] - if still_running: - with suppress(asyncio.CancelledError): + proc.terminate() try: - await asyncio.shield( - asyncio.wait_for( - asyncio.gather(*[p.wait() for p in still_running]), - timeout=10, - ), - ) + await asyncio.wait_for(proc.wait(), self._terminate_timeout) except TimeoutError: - for p in self._procs: - if p.returncode is None: - with suppress(ProcessLookupError): - p.kill() - await p.wait() + with suppress(ProcessLookupError): + proc.kill() + await proc.wait() + + if vital: + logger.warning("Vital process %s exited", name) + raise CalledProcessError(returncode, cmd) + + started = asyncio.get_running_loop().create_future() + self.create_task(run()) + return await asyncio.shield(started) + + async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]: + """Wait concurrently and return results in argument order.""" + + async def task(w: Process | Awaitable) -> Any: + if not isinstance(w, Process): + return await w + if retcode := await w.wait(): + cmd = self._cmds[w] + logger.warning( + "Process %s exited with status %d", Path(cmd[0]).stem, retcode + ) + raise CalledProcessError(retcode, cmd) + return retcode + + async with asyncio.TaskGroup() as group: + tasks = [group.create_task(task(w)) for w in waitables] + + return tuple(task.result() for task in tasks) -async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109 +async def http_get_server(url: str, timeout: float) -> str | None: """GET url with plain asyncio streams, return the response Server header. Returns an empty string when the server responds without a Server header, @@ -128,31 +107,32 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN writer.close() except OSError, EOFError, ValueError, TimeoutError: return None - for line in data.decode("latin-1").split("\r\n"): + for line in data.decode(errors="replace").split("\r\n"): if line.lower().startswith("server:"): - return line.split(":", 1)[1].strip() + return line[7:].strip() return "" async def check_ports_free(*urls: str) -> None: - """Verify URLs are not responding (ports are free). Raise SystemExit if any respond.""" + """Verify URLs are not responding (ports are free). - async def check(url: str) -> None: - server = await http_get_server(url, timeout=0.1) + Meant to run as a task inside a TaskGroup. Logs the conflict and raises + RuntimeError (handled like a failed process) if any URL responds. + """ + servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls)) + for url, server in zip(urls, servers, strict=True): if server is not None: - logger.warning( + logger.error( "Conflicting %s already running at %s", server or "server", url ) - raise SystemExit(1) - - await asyncio.gather(*[check(url) for url in urls]) + raise RuntimeError(url) async def ready(url: str, path: str = "", max_attempts: int = 50) -> None: """Wait for the server to be ready by polling an endpoint. Use empty path to disable the check and make this return immediately. - Raises SystemExit(1) if server doesn't start in time. + Logs, then raises RuntimeError if the server doesn't start in time. """ if not path: return @@ -162,8 +142,8 @@ async def ready(url: str, path: str = "", max_attempts: int = 50) -> None: logger.info("✓ Backend ready!") return if attempt == max_attempts - 1: - logger.warning("Backend didn't start in time") - raise SystemExit(1) + logger.error("Backend at %s didn't start in time", url) + raise RuntimeError(url) await asyncio.sleep(0.1)