diff --git a/fastapi-vue/fastapi_vue/accesslog.py b/fastapi-vue/fastapi_vue/accesslog.py index 1973cd7..6c00b54 100644 --- a/fastapi-vue/fastapi_vue/accesslog.py +++ b/fastapi-vue/fastapi_vue/accesslog.py @@ -179,8 +179,8 @@ def _http_access_log_extra( ) -> dict[str, object]: client_addr = _client_host(scope) full_path = _path(scope) - method = method if method is not None else cast(str, scope.get("method", "-")) - method = cast(str, scope.get("state", {}).get("access_log_method") or method) + method = method if method is not None else cast("str", scope.get("method", "-")) + method = cast("str", scope.get("state", {}).get("access_log_method") or method) try: status_phrase = http.HTTPStatus(status).phrase @@ -318,18 +318,21 @@ def _assemble_access_log(fields: dict[str, object]) -> str: class AccessLogMiddleware: + """ASGI middleware logging HTTP and WebSocket access with colored fields.""" + def __init__(self, app: ASGI3Application) -> None: + """Store the wrapped app.""" self.app = app async def __call__( self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable ) -> None: + """Dispatch by scope type to HTTP/WebSocket access logging.""" if scope["type"] == "http": return await self._handle_http(scope, receive, send) - elif scope["type"] == "websocket": + if scope["type"] == "websocket": return await self._handle_websocket(scope, receive, send) - else: - return await self.app(scope, receive, send) + return await self.app(scope, receive, send) async def _handle_http( self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable @@ -346,7 +349,10 @@ class AccessLogMiddleware: extra=www_scope.get("state", {}).get("log_extra", ""), ) logger.info( - f'{fields["client_addr"]} - "{fields["request_line"]}" {fields["status_code"]}', + '%s - "%s" %s', + fields["client_addr"], + fields["request_line"], + fields["status_code"], extra=fields, ) await send(message) @@ -369,7 +375,7 @@ class AccessLogMiddleware: def _close_fields(message: ASGIReceiveEvent | ASGISendEvent) -> dict[str, object]: if accepted: - assert ws_id is not None + assert ws_id is not None # noqa: S101 # guaranteed once accepted return _ws_close_extra( www_scope, ws_id, diff --git a/fastapi-vue/fastapi_vue/logging.py b/fastapi-vue/fastapi_vue/logging.py index ab302c6..8a8cdf7 100644 --- a/fastapi-vue/fastapi_vue/logging.py +++ b/fastapi-vue/fastapi_vue/logging.py @@ -28,6 +28,7 @@ ACCESS_LOGGER = "fastapi_vue.access" def strip_ansi(text: str) -> str: + """Remove ANSI escape codes from text.""" return ANSI_ESCAPE_RE.sub("", text) @@ -51,7 +52,7 @@ class Formatter(logging.Formatter): Records with the middleware's access fields (``client`` etc.) are formatted from those; anything else gets an emoji level prefix (``LEVEL: `` fallback for unknown levels) in place of uvicorn's - ``levelprefix``. ANSI codes are stripped when colors are disabled. + ``levelprefix``. Instantiation always loads tracerite, and with ``access=True`` also installs the access-log middleware: ``dictConfig`` builds formatters while @@ -65,10 +66,11 @@ class Formatter(logging.Formatter): fmt: str | None = None, datefmt: str | None = None, style: Literal["%", "{", "$"] = "%", - use_colors: bool | None = None, + use_colors: bool | None = None, # noqa: FBT001 # mirrors logging.Formatter *, access: bool = False, ) -> None: + """Load tracerite, optionally install the access log, detect color support.""" tracerite.load() if access: install_access_log() @@ -78,7 +80,8 @@ class Formatter(logging.Formatter): self.use_colors = sys.stdout.isatty() super().__init__(fmt=fmt, datefmt=datefmt, style=style) - def formatMessage(self, record: logging.LogRecord) -> str: + def formatMessage(self, record: logging.LogRecord) -> str: # noqa: N802 + """Format access records via middleware fields, others with an emoji prefix.""" if "client" not in record.__dict__: return _level_prefix(record) + record.getMessage() formatted = super().formatMessage(record) @@ -98,6 +101,7 @@ class WebSocketChatterFilter(logging.Filter): _PREFIXES = ('%s - "WebSocket ', "connection open", "connection closed", "connection rejected") def filter(self, record: logging.LogRecord) -> bool: + """Keep records not matching stock WebSocket chatter prefixes.""" msg = record.msg if not isinstance(msg, str): return True @@ -113,6 +117,7 @@ class UvicornQuietFilter(logging.Filter): """ def filter(self, record: logging.LogRecord) -> bool: + """Drop uvicorn records below WARNING.""" return not (record.name.startswith("uvicorn") and record.levelno < logging.WARNING) @@ -125,7 +130,7 @@ def install_access_log() -> None: The guard is deliberately module-level: reload/worker subprocesses re-import this module, resetting it so the patch is re-applied there. """ - global _installed + global _installed # noqa: PLW0603 # deliberately module-level, see docstring if _installed: return _installed = True diff --git a/fastapi_vue_setup.py b/fastapi_vue_setup.py index fe8fa45..76b9941 100644 --- a/fastapi_vue_setup.py +++ b/fastapi_vue_setup.py @@ -1596,12 +1596,16 @@ def cmd_setup(args: argparse.Namespace) -> int: print("✅ Created .gitignore") # === Add dependencies using uv === + # Pin fastapi-vue to the same major.minor as this setup tool (both are released + # from the same tags). Patch/dev releases may deviate, which also keeps this + # resolvable when running a development version of fastapi-vue-setup. + mm = re.match(r"(\d+)\.(\d+)", version) + fastapi_vue_req = f"fastapi-vue~={mm[1]}.{mm[2]}.0" if mm else "fastapi-vue" if dry: - print("📦 Would add: fastapi[standard], fastapi-vue, httpx (dev only)") + print(f"📦 Would add: fastapi[standard], {fastapi_vue_req}") else: print("📦 Dependencies") - uv_add_packages(["fastapi[standard]", "fastapi-vue"], cwd=project_dir) - uv_add_packages(["httpx"], cwd=project_dir, group="dev") + uv_add_packages(["fastapi[standard]", fastapi_vue_req], cwd=project_dir) print() print_boxed("Setup complete!") diff --git a/pyproject.toml b/pyproject.toml index f65761c..f06cf9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ line-length = 100 [tool.ruff.lint] select = ["ALL"] -ignore = ["D203", "D213", "COM812"] # Conflicting with D211, D212 and formatting +ignore = ["CPY", "D203", "D213", "COM812", "PLR2004"] [tool.ruff.lint.per-file-ignores] "template/**" = ["F821"] # Undefined names are template placeholders diff --git a/template/scripts/fastapi-vue/devutil.py b/template/scripts/fastapi-vue/devutil.py index d691801..e268753 100644 --- a/template/scripts/fastapi-vue/devutil.py +++ b/template/scripts/fastapi-vue/devutil.py @@ -7,8 +7,8 @@ import sys from contextlib import suppress from pathlib import Path from typing import TYPE_CHECKING, Any, Self +from urllib.parse import urlsplit -import httpx from buildutil import find_dev_tool, find_install_tool, logger from fastapi_vue.hostutil import parse_endpoint @@ -104,18 +104,45 @@ class ProcessGroup: await p.wait() +async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109 + """GET url with plain asyncio streams, return the response Server header. + + Returns an empty string when the server responds without a Server header, + and None when the server is unreachable or doesn't answer in time. + """ + parts = urlsplit(url) + host = parts.hostname or "localhost" + port = parts.port or (443 if parts.scheme == "https" else 80) + path = parts.path or "/" + if parts.query: + path += f"?{parts.query}" + try: + async with asyncio.timeout(timeout): + reader, writer = await asyncio.open_connection(host, port) + try: + writer.write(f"GET {path} HTTP/1.0\r\nHost: {host}\r\n\r\n".encode()) + await writer.drain() + data = await reader.readuntil(b"\r\n\r\n") + finally: + writer.close() + except (OSError, EOFError, ValueError, TimeoutError): + return None + for line in data.decode("latin-1").split("\r\n"): + if line.lower().startswith("server:"): + return line.split(":", 1)[1].strip() + return "" + + async def check_ports_free(*urls: str) -> None: """Verify URLs are not responding (ports are free). Raise SystemExit if any respond.""" - async def check(client: httpx.AsyncClient, url: str) -> None: - with suppress(httpx.RequestError): - res = await client.get(url, timeout=0.1) - server = res.headers.get("server", "server") - logger.warning("Conflicting %s already running at %s", server, url) + async def check(url: str) -> None: + server = await http_get_server(url, timeout=0.1) + if server is not None: + logger.warning("Conflicting %s already running at %s", server or "server", url) raise SystemExit(1) - async with httpx.AsyncClient() as client: - await asyncio.gather(*[check(client, url) for url in urls]) + await asyncio.gather(*[check(url) for url in urls]) async def ready(url: str, path: str = "", max_attempts: int = 50) -> None: @@ -127,18 +154,14 @@ async def ready(url: str, path: str = "", max_attempts: int = 50) -> None: if not path: return - async with httpx.AsyncClient() as client: - for attempt in range(max_attempts): - try: - await client.get(f"{url}{path}", timeout=1.0) - except httpx.RequestError: - if attempt == max_attempts - 1: - logger.warning("Backend didn't start in time") - raise SystemExit(1) from None - await asyncio.sleep(0.1) - else: - logger.info("✓ Backend ready!") - return + for attempt in range(max_attempts): + if await http_get_server(f"{url}{path}", timeout=1.0) is not None: + logger.info("✓ Backend ready!") + return + if attempt == max_attempts - 1: + logger.warning("Backend didn't start in time") + raise SystemExit(1) + await asyncio.sleep(0.1) def setup_vite(