Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
465206067b | ||
|
|
c869783b0b | ||
|
|
c7577e9ff7 | ||
|
|
fa287eba20 | ||
|
|
0f5b526df6 |
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
"""FastAPI Vue integration - serve Vue frontend from FastAPI."""
|
"""FastAPI Vue integration - serve Vue frontend from FastAPI."""
|
||||||
|
|
||||||
from .environ import env, teleport
|
from .environ import env, teleport
|
||||||
|
from .logging import setup_logging
|
||||||
from .staticfiles import Frontend
|
from .staticfiles import Frontend
|
||||||
|
|
||||||
__all__ = ["Frontend", "env", "teleport"]
|
__all__ = ["Frontend", "env", "setup_logging", "teleport"]
|
||||||
@@ -9,7 +9,7 @@ import time
|
|||||||
from ipaddress import IPv6Address
|
from ipaddress import IPv6Address
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from .termwidth import pad_display
|
from .tty import pad_display, use_color
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from uvicorn._types import (
|
from uvicorn._types import (
|
||||||
@@ -388,6 +388,7 @@ class AccessLogMiddleware:
|
|||||||
if message["type"] == "websocket.accept" and not accepted:
|
if message["type"] == "websocket.accept" and not accepted:
|
||||||
accepted = True
|
accepted = True
|
||||||
ws_id = _next_ws_id()
|
ws_id = _next_ws_id()
|
||||||
|
www_scope["state"]["logid"] = f"{_WS_OPEN}{ws_id}{_RESET}" if use_color() else ws_id
|
||||||
fields = _ws_open_extra(www_scope, ws_id, origin, _extra())
|
fields = _ws_open_extra(www_scope, ws_id, origin, _extra())
|
||||||
logger.info(_assemble_access_log(fields), extra=fields)
|
logger.info(_assemble_access_log(fields), extra=fields)
|
||||||
elif message["type"] == "websocket.http.response.start" and not closed:
|
elif message["type"] == "websocket.http.response.start" and not closed:
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
@@ -32,34 +30,15 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from .accesslog import AccessLogMiddleware
|
from .accesslog import AccessLogMiddleware
|
||||||
from .environ import env
|
from .environ import env
|
||||||
|
from .tty import strip_ansi, use_color
|
||||||
|
|
||||||
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
|
RESET = "\033[0m"
|
||||||
|
|
||||||
ACCESS_LOG_FMT = "%(client)s %(status)s %(method)s %(host)s%(path)s %(extra)s%(timing)s"
|
ACCESS_LOG_FMT = "%(client)s %(status)s %(method)s %(host)s%(path)s %(extra)s%(timing)s"
|
||||||
|
|
||||||
ACCESS_LOGGER = "fastapi_vue.access"
|
ACCESS_LOGGER = "fastapi_vue.access"
|
||||||
|
|
||||||
|
|
||||||
def strip_ansi(text: str) -> str:
|
|
||||||
"""Remove ANSI escape codes from text."""
|
|
||||||
return ANSI_ESCAPE_RE.sub("", text)
|
|
||||||
|
|
||||||
|
|
||||||
def use_color(stream: io.TextIOBase = sys.stderr) -> bool:
|
|
||||||
"""Test if the stream supports color codes."""
|
|
||||||
if os.environ.get("NO_COLOR"): # Non empty means no (no-color.org)
|
|
||||||
return False
|
|
||||||
if os.environ.get("FORCE_COLOR", "") not in {"", "0"}: # force-color.org, node
|
|
||||||
return True
|
|
||||||
if hasattr(stream, "isatty") and stream.isatty():
|
|
||||||
return True
|
|
||||||
with suppress(KeyError, ValueError, OSError): # Journald does color (-ocat)
|
|
||||||
dev, ino = map(int, os.environ["JOURNAL_STREAM"].split(":", 1))
|
|
||||||
st = os.fstat(stream.fileno())
|
|
||||||
return st.st_dev == dev and st.st_ino == ino
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
_LEVEL_EMOJI = {
|
_LEVEL_EMOJI = {
|
||||||
logging.DEBUG: "🐛",
|
logging.DEBUG: "🐛",
|
||||||
logging.INFO: "🔷",
|
logging.INFO: "🔷",
|
||||||
@@ -98,14 +77,16 @@ class Formatter(logging.Formatter):
|
|||||||
use_colors: bool | None = None, # noqa: FBT001 # mirrors logging.Formatter
|
use_colors: bool | None = None, # noqa: FBT001 # mirrors logging.Formatter
|
||||||
*,
|
*,
|
||||||
access: bool = False,
|
access: bool = False,
|
||||||
|
install: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Load tracerite, optionally install the access log, detect color support."""
|
"""Load tracerite, optionally install server patches and access log."""
|
||||||
tracerite.load()
|
tracerite.load()
|
||||||
tracerite.load_suppressions(
|
tracerite.load_suppressions(
|
||||||
extra={"starlette.routing": "until", "fastapi.routing": "until"}
|
extra={"starlette.routing": "until", "fastapi.routing": "until"}
|
||||||
)
|
)
|
||||||
patch_lifespan_logging()
|
if install:
|
||||||
patch_server_error_middleware()
|
patch_lifespan_logging()
|
||||||
|
patch_server_error_middleware()
|
||||||
if access:
|
if access:
|
||||||
install_access_log()
|
install_access_log()
|
||||||
if use_colors in (True, False):
|
if use_colors in (True, False):
|
||||||
@@ -120,7 +101,14 @@ class Formatter(logging.Formatter):
|
|||||||
return _level_prefix(record) + record.getMessage()
|
return _level_prefix(record) + record.getMessage()
|
||||||
formatted = super().formatMessage(record)
|
formatted = super().formatMessage(record)
|
||||||
if not self.use_colors:
|
if not self.use_colors:
|
||||||
formatted = strip_ansi(formatted)
|
return strip_ansi(formatted)
|
||||||
|
# Guard against app-supplied fields (``extra``) carrying raw color
|
||||||
|
# codes without a reset: ensure the line begins and ends with a
|
||||||
|
# reset, but only add one where it's missing to avoid duplicates.
|
||||||
|
if not formatted.startswith(RESET):
|
||||||
|
formatted = RESET + formatted
|
||||||
|
if not formatted.endswith(RESET):
|
||||||
|
formatted += RESET
|
||||||
return formatted
|
return formatted
|
||||||
|
|
||||||
|
|
||||||
@@ -309,7 +297,35 @@ def _merge_log_config(base: dict, overlay: dict) -> dict:
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, ANN201
|
def setup_logging(*, log_config: dict | None = None, dev: bool | None = None) -> None:
|
||||||
|
"""Set up pretty logging standalone, outside of ``server.run()``.
|
||||||
|
|
||||||
|
Optional helper for CLI mains, devservers and scripts that log before
|
||||||
|
(or without) starting the server. Loads tracerite directly and applies
|
||||||
|
the same patching as the server path (see ``patch_log_config``, a
|
||||||
|
private helper) with ``logging.config.dictConfig``: partial dicts merge
|
||||||
|
over uvicorn's default config, so only customizations are needed. The
|
||||||
|
server-side patches (error middleware, access log) are not installed —
|
||||||
|
there is no server here. The root logger level is INFO with *dev*
|
||||||
|
true, WARNING otherwise; *dev* of None follows ``env.dev``. An
|
||||||
|
explicit level in *log_config* always wins::
|
||||||
|
|
||||||
|
import fastapi_vue
|
||||||
|
fastapi_vue.setup_logging(log_config={"loggers": {"myapp": {"level": "DEBUG"}}})
|
||||||
|
"""
|
||||||
|
if log_config is not None and not isinstance(log_config, dict):
|
||||||
|
msg = f"setup_logging requires a dict log_config, got {type(log_config).__name__}"
|
||||||
|
raise TypeError(msg)
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
tracerite.load()
|
||||||
|
config = patch_log_config(log_config or {}, access_log=False, install=False, dev=dev)
|
||||||
|
# Standalone setup must not disable loggers created before this call.
|
||||||
|
config.setdefault("disable_existing_loggers", False)
|
||||||
|
logging.config.dictConfig(config)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_log_config(log_config, *, access_log: bool = True, install: bool = True, dev: bool | None = None): # noqa: ANN001, ANN201
|
||||||
"""Patch a uvicorn log_config dict for our logging, best-effort.
|
"""Patch a uvicorn log_config dict for our logging, best-effort.
|
||||||
|
|
||||||
A dict without a ``version`` key is treated as a partial config: it is
|
A dict without a ``version`` key is treated as a partial config: it is
|
||||||
@@ -330,9 +346,11 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
|
|||||||
``watchfiles.main`` logger is lifted to WARNING so its INFO "N changes
|
``watchfiles.main`` logger is lifted to WARNING so its INFO "N changes
|
||||||
detected" line is dropped while the WARNING "Reloading..." line (logged
|
detected" line is dropped while the WARNING "Reloading..." line (logged
|
||||||
to ``uvicorn.error``) still shows; a user-supplied level wins.
|
to ``uvicorn.error``) still shows; a user-supplied level wins.
|
||||||
With ``access_log``, additionally rewires the ``access`` formatter to
|
With ``install=False`` (standalone use via ``setup_logging``) the
|
||||||
our Formatter and attaches its handler to our ``fastapi_vue.access``
|
NullHandler backdoor and the server-side patches in Formatter are
|
||||||
logger. We must not
|
skipped. With ``access_log``, additionally rewires the ``access``
|
||||||
|
formatter to our Formatter and attaches its handler to our
|
||||||
|
``fastapi_vue.access`` logger. We must not
|
||||||
attach handlers to ``uvicorn.access``: uvicorn gates its own
|
attach handlers to ``uvicorn.access``: uvicorn gates its own
|
||||||
protocol-level access logging on ``uvicorn.access.hasHandlers()``.
|
protocol-level access logging on ``uvicorn.access.hasHandlers()``.
|
||||||
"""
|
"""
|
||||||
@@ -342,12 +360,13 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
|
|||||||
if "version" not in config:
|
if "version" not in config:
|
||||||
config = _merge_log_config(deepcopy(LOGGING_CONFIG), config)
|
config = _merge_log_config(deepcopy(LOGGING_CONFIG), config)
|
||||||
|
|
||||||
with suppress(Exception):
|
if install:
|
||||||
config["formatters"]["fastapi_vue"] = {"()": "fastapi_vue.logging.Formatter"}
|
with suppress(Exception):
|
||||||
config["handlers"]["fastapi_vue"] = {
|
config["formatters"]["fastapi_vue"] = {"()": "fastapi_vue.logging.Formatter"}
|
||||||
"class": "logging.NullHandler",
|
config["handlers"]["fastapi_vue"] = {
|
||||||
"formatter": "fastapi_vue",
|
"class": "logging.NullHandler",
|
||||||
}
|
"formatter": "fastapi_vue",
|
||||||
|
}
|
||||||
|
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
filters = config.setdefault("filters", {})
|
filters = config.setdefault("filters", {})
|
||||||
@@ -367,6 +386,7 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
|
|||||||
"()": "fastapi_vue.logging.Formatter",
|
"()": "fastapi_vue.logging.Formatter",
|
||||||
"fmt": "%(message)s",
|
"fmt": "%(message)s",
|
||||||
"use_colors": None,
|
"use_colors": None,
|
||||||
|
"install": install,
|
||||||
}
|
}
|
||||||
|
|
||||||
# uvicorn's default config leaves the root logger handlerless, eating
|
# uvicorn's default config leaves the root logger handlerless, eating
|
||||||
@@ -375,7 +395,7 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
|
|||||||
# with Python's default; dev keeps INFO. Subloggers can override.
|
# with Python's default; dev keeps INFO. Subloggers can override.
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
root = config.setdefault("root", {})
|
root = config.setdefault("root", {})
|
||||||
root.setdefault("level", "INFO" if env.dev else "WARNING")
|
root.setdefault("level", "INFO" if (env.dev if dev is None else dev) else "WARNING")
|
||||||
if "default" in config.get("handlers", {}):
|
if "default" in config.get("handlers", {}):
|
||||||
root_handlers = root.setdefault("handlers", [])
|
root_handlers = root.setdefault("handlers", [])
|
||||||
if "default" not in root_handlers:
|
if "default" not in root_handlers:
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ from .logging import (
|
|||||||
patch_lifespan_logging,
|
patch_lifespan_logging,
|
||||||
patch_log_config,
|
patch_log_config,
|
||||||
patch_server_error_middleware,
|
patch_server_error_middleware,
|
||||||
use_color,
|
|
||||||
)
|
)
|
||||||
from .startupbox import print_box
|
from .startupbox import print_box
|
||||||
|
from .tty import use_color
|
||||||
|
|
||||||
tracerite.load() # Early load on CLI load (import server); uvicorn workers reload via log config
|
tracerite.load() # Early load on CLI load (import server); uvicorn workers reload via log config
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .termwidth import display_width, pad_display
|
from .tty import display_width, pad_display
|
||||||
|
|
||||||
|
|
||||||
def print_box(text: str) -> None:
|
def print_box(text: str) -> None:
|
||||||
"""Print text in a rounded unicode box sized to its longest line.
|
"""Print text in a rounded unicode box sized to its longest line.
|
||||||
|
|
||||||
The text is split on newlines as-is (not trimmed). Padding accounts
|
The text is split on newlines as-is (not trimmed). Padding accounts
|
||||||
for ANSI colors and unicode display width (see termwidth.display_width).
|
for ANSI colors and unicode display width (see tty.display_width).
|
||||||
"""
|
"""
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
width = max(map(display_width, lines))
|
width = max(map(display_width, lines))
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
"""Terminal display width calculation for unicode and ANSI-colored text."""
|
"""Terminal color detection, ANSI helpers, and display width calculation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;:]*[A-Za-z]")
|
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;:]*[A-Za-z]")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_ansi(text: str) -> str:
|
||||||
|
"""Remove ANSI escape codes from text."""
|
||||||
|
return ANSI_ESCAPE_RE.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
|
def use_color(stream: io.TextIOBase = sys.stderr) -> bool:
|
||||||
|
"""Test if the stream supports color codes."""
|
||||||
|
if os.environ.get("NO_COLOR"): # Non empty means no (no-color.org)
|
||||||
|
return False
|
||||||
|
if os.environ.get("FORCE_COLOR", "") not in {"", "0"}: # force-color.org, node
|
||||||
|
return True
|
||||||
|
if hasattr(stream, "isatty") and stream.isatty():
|
||||||
|
return True
|
||||||
|
with suppress(KeyError, ValueError, OSError): # Journald does color (-ocat)
|
||||||
|
dev, ino = map(int, os.environ["JOURNAL_STREAM"].split(":", 1))
|
||||||
|
st = os.fstat(stream.fileno())
|
||||||
|
return st.st_dev == dev and st.st_ino == ino
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_wide(char: str) -> bool:
|
def _is_wide(char: str) -> bool:
|
||||||
"""Return True for characters rendered as two terminal columns."""
|
"""Return True for characters rendered as two terminal columns."""
|
||||||
if unicodedata.east_asian_width(char) in {"F", "W"}:
|
if unicodedata.east_asian_width(char) in {"F", "W"}:
|
||||||
@@ -10,21 +10,27 @@ from pathlib import Path
|
|||||||
|
|
||||||
MIN_NODE_VERSION = 20
|
MIN_NODE_VERSION = 20
|
||||||
|
|
||||||
|
class _Formatter(logging.Formatter):
|
||||||
|
"""Prefix formatter, intentionally different from fastapi_vue.logging.
|
||||||
|
|
||||||
class _PrefixFormatter(logging.Formatter):
|
INFO and below pass through unprefixed so messages can use their own
|
||||||
"""Formatter that adds prefix based on log level."""
|
markings (>>>, ###); WARNING and above get an emoji prefix.
|
||||||
|
"""
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
if record.levelno >= logging.ERROR:
|
||||||
|
return f"🛑 {record.getMessage()}"
|
||||||
if record.levelno >= logging.WARNING:
|
if record.levelno >= logging.WARNING:
|
||||||
return f"⚠️ {record.getMessage()}"
|
return f"💣 {record.getMessage()}"
|
||||||
return record.getMessage()
|
return record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
_handler = logging.StreamHandler()
|
_handler = logging.StreamHandler()
|
||||||
_handler.setFormatter(_PrefixFormatter())
|
_handler.setFormatter(_Formatter())
|
||||||
logger = logging.getLogger("fastapi-vue")
|
logger = logging.getLogger("fastapi-vue")
|
||||||
logger.addHandler(_handler)
|
logger.addHandler(_handler)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.propagate = False # own handler; do not double-print via a configured root
|
||||||
|
|
||||||
|
|
||||||
def _check_node_version(node_path: str) -> None:
|
def _check_node_version(node_path: str) -> None:
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
|||||||
|
|
||||||
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:
|
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||||
logger.info("✓ Backend ready!")
|
logger.info("🟢 Backend ready!")
|
||||||
return
|
return
|
||||||
if attempt == max_attempts - 1:
|
if attempt == max_attempts - 1:
|
||||||
logger.error("Backend at %s didn't start in time", url)
|
logger.error("Backend at %s didn't start in time", url)
|
||||||
|
|||||||
Reference in new issue
Block a user