Target project no longer depends on httpx but depends on ~matching fastapi-vue version. Ruff cleanup.
This commit is contained in:
@@ -179,8 +179,8 @@ def _http_access_log_extra(
|
|||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
client_addr = _client_host(scope)
|
client_addr = _client_host(scope)
|
||||||
full_path = _path(scope)
|
full_path = _path(scope)
|
||||||
method = method if method is not None else cast(str, scope.get("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)
|
method = cast("str", scope.get("state", {}).get("access_log_method") or method)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status_phrase = http.HTTPStatus(status).phrase
|
status_phrase = http.HTTPStatus(status).phrase
|
||||||
@@ -318,18 +318,21 @@ def _assemble_access_log(fields: dict[str, object]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class AccessLogMiddleware:
|
class AccessLogMiddleware:
|
||||||
|
"""ASGI middleware logging HTTP and WebSocket access with colored fields."""
|
||||||
|
|
||||||
def __init__(self, app: ASGI3Application) -> None:
|
def __init__(self, app: ASGI3Application) -> None:
|
||||||
|
"""Store the wrapped app."""
|
||||||
self.app = app
|
self.app = app
|
||||||
|
|
||||||
async def __call__(
|
async def __call__(
|
||||||
self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
|
self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Dispatch by scope type to HTTP/WebSocket access logging."""
|
||||||
if scope["type"] == "http":
|
if scope["type"] == "http":
|
||||||
return await self._handle_http(scope, receive, send)
|
return await self._handle_http(scope, receive, send)
|
||||||
elif scope["type"] == "websocket":
|
if scope["type"] == "websocket":
|
||||||
return await self._handle_websocket(scope, receive, send)
|
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(
|
async def _handle_http(
|
||||||
self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
|
self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
|
||||||
@@ -346,7 +349,10 @@ class AccessLogMiddleware:
|
|||||||
extra=www_scope.get("state", {}).get("log_extra", ""),
|
extra=www_scope.get("state", {}).get("log_extra", ""),
|
||||||
)
|
)
|
||||||
logger.info(
|
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,
|
extra=fields,
|
||||||
)
|
)
|
||||||
await send(message)
|
await send(message)
|
||||||
@@ -369,7 +375,7 @@ class AccessLogMiddleware:
|
|||||||
|
|
||||||
def _close_fields(message: ASGIReceiveEvent | ASGISendEvent) -> dict[str, object]:
|
def _close_fields(message: ASGIReceiveEvent | ASGISendEvent) -> dict[str, object]:
|
||||||
if accepted:
|
if accepted:
|
||||||
assert ws_id is not None
|
assert ws_id is not None # noqa: S101 # guaranteed once accepted
|
||||||
return _ws_close_extra(
|
return _ws_close_extra(
|
||||||
www_scope,
|
www_scope,
|
||||||
ws_id,
|
ws_id,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ ACCESS_LOGGER = "fastapi_vue.access"
|
|||||||
|
|
||||||
|
|
||||||
def strip_ansi(text: str) -> str:
|
def strip_ansi(text: str) -> str:
|
||||||
|
"""Remove ANSI escape codes from text."""
|
||||||
return ANSI_ESCAPE_RE.sub("", 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
|
Records with the middleware's access fields (``client`` etc.) are
|
||||||
formatted from those; anything else gets an emoji level prefix
|
formatted from those; anything else gets an emoji level prefix
|
||||||
(``LEVEL: `` fallback for unknown levels) in place of uvicorn's
|
(``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
|
Instantiation always loads tracerite, and with ``access=True`` also
|
||||||
installs the access-log middleware: ``dictConfig`` builds formatters while
|
installs the access-log middleware: ``dictConfig`` builds formatters while
|
||||||
@@ -65,10 +66,11 @@ class Formatter(logging.Formatter):
|
|||||||
fmt: str | None = None,
|
fmt: str | None = None,
|
||||||
datefmt: str | None = None,
|
datefmt: str | None = None,
|
||||||
style: Literal["%", "{", "$"] = "%",
|
style: Literal["%", "{", "$"] = "%",
|
||||||
use_colors: bool | None = None,
|
use_colors: bool | None = None, # noqa: FBT001 # mirrors logging.Formatter
|
||||||
*,
|
*,
|
||||||
access: bool = False,
|
access: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Load tracerite, optionally install the access log, detect color support."""
|
||||||
tracerite.load()
|
tracerite.load()
|
||||||
if access:
|
if access:
|
||||||
install_access_log()
|
install_access_log()
|
||||||
@@ -78,7 +80,8 @@ class Formatter(logging.Formatter):
|
|||||||
self.use_colors = sys.stdout.isatty()
|
self.use_colors = sys.stdout.isatty()
|
||||||
super().__init__(fmt=fmt, datefmt=datefmt, style=style)
|
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__:
|
if "client" not in record.__dict__:
|
||||||
return _level_prefix(record) + record.getMessage()
|
return _level_prefix(record) + record.getMessage()
|
||||||
formatted = super().formatMessage(record)
|
formatted = super().formatMessage(record)
|
||||||
@@ -98,6 +101,7 @@ class WebSocketChatterFilter(logging.Filter):
|
|||||||
_PREFIXES = ('%s - "WebSocket ', "connection open", "connection closed", "connection rejected")
|
_PREFIXES = ('%s - "WebSocket ', "connection open", "connection closed", "connection rejected")
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
"""Keep records not matching stock WebSocket chatter prefixes."""
|
||||||
msg = record.msg
|
msg = record.msg
|
||||||
if not isinstance(msg, str):
|
if not isinstance(msg, str):
|
||||||
return True
|
return True
|
||||||
@@ -113,6 +117,7 @@ class UvicornQuietFilter(logging.Filter):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
"""Drop uvicorn records below WARNING."""
|
||||||
return not (record.name.startswith("uvicorn") and record.levelno < logging.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
|
The guard is deliberately module-level: reload/worker subprocesses
|
||||||
re-import this module, resetting it so the patch is re-applied there.
|
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:
|
if _installed:
|
||||||
return
|
return
|
||||||
_installed = True
|
_installed = True
|
||||||
|
|||||||
@@ -1596,12 +1596,16 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
print("✅ Created .gitignore")
|
print("✅ Created .gitignore")
|
||||||
|
|
||||||
# === Add dependencies using uv ===
|
# === 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:
|
if dry:
|
||||||
print("📦 Would add: fastapi[standard], fastapi-vue, httpx (dev only)")
|
print(f"📦 Would add: fastapi[standard], {fastapi_vue_req}")
|
||||||
else:
|
else:
|
||||||
print("📦 Dependencies")
|
print("📦 Dependencies")
|
||||||
uv_add_packages(["fastapi[standard]", "fastapi-vue"], cwd=project_dir)
|
uv_add_packages(["fastapi[standard]", fastapi_vue_req], cwd=project_dir)
|
||||||
uv_add_packages(["httpx"], cwd=project_dir, group="dev")
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print_boxed("Setup complete!")
|
print_boxed("Setup complete!")
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ line-length = 100
|
|||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["ALL"]
|
select = ["ALL"]
|
||||||
ignore = ["D203", "D213", "COM812"] # Conflicting with D211, D212 and formatting
|
ignore = ["CPY", "D203", "D213", "COM812", "PLR2004"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"template/**" = ["F821"] # Undefined names are template placeholders
|
"template/**" = ["F821"] # Undefined names are template placeholders
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import sys
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Self
|
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 buildutil import find_dev_tool, find_install_tool, logger
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
@@ -104,18 +104,45 @@ class ProcessGroup:
|
|||||||
await p.wait()
|
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:
|
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). Raise SystemExit if any respond."""
|
||||||
|
|
||||||
async def check(client: httpx.AsyncClient, url: str) -> None:
|
async def check(url: str) -> None:
|
||||||
with suppress(httpx.RequestError):
|
server = await http_get_server(url, timeout=0.1)
|
||||||
res = await client.get(url, timeout=0.1)
|
if server is not None:
|
||||||
server = res.headers.get("server", "server")
|
logger.warning("Conflicting %s already running at %s", server or "server", url)
|
||||||
logger.warning("Conflicting %s already running at %s", server, url)
|
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
await asyncio.gather(*[check(url) for url in urls])
|
||||||
await asyncio.gather(*[check(client, url) for url in urls])
|
|
||||||
|
|
||||||
|
|
||||||
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
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:
|
if not path:
|
||||||
return
|
return
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
for attempt in range(max_attempts):
|
||||||
for attempt in range(max_attempts):
|
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||||
try:
|
logger.info("✓ Backend ready!")
|
||||||
await client.get(f"{url}{path}", timeout=1.0)
|
return
|
||||||
except httpx.RequestError:
|
if attempt == max_attempts - 1:
|
||||||
if attempt == max_attempts - 1:
|
logger.warning("Backend didn't start in time")
|
||||||
logger.warning("Backend didn't start in time")
|
raise SystemExit(1)
|
||||||
raise SystemExit(1) from None
|
await asyncio.sleep(0.1)
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
else:
|
|
||||||
logger.info("✓ Backend ready!")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def setup_vite(
|
def setup_vite(
|
||||||
|
|||||||
Reference in New Issue
Block a user