191 lines
5.6 KiB
Python
191 lines
5.6 KiB
Python
import logging
|
|
import sys
|
|
from typing import Any
|
|
|
|
from rich._loop import loop_first
|
|
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
|
from rich.segment import Segment
|
|
from rich.text import Text
|
|
from rich_toolkit import RichToolkit
|
|
from rich_toolkit.element import Element
|
|
from rich_toolkit.styles import BaseStyle
|
|
from uvicorn.logging import DefaultFormatter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def should_use_rich_logs() -> bool:
|
|
"""Return True when stdout is a TTY and rich logs should be used, False otherwise."""
|
|
return sys.stdout.isatty()
|
|
|
|
|
|
class IndentedBlock:
|
|
"""Indent a renderable, hanging a prefix (e.g. an emoji bullet) on the
|
|
first line and aligning wrapped/extra lines under the text."""
|
|
|
|
def __init__(
|
|
self,
|
|
renderable: RenderableType,
|
|
*,
|
|
first_prefix: Text,
|
|
prefix: Text,
|
|
) -> None:
|
|
self.renderable = renderable
|
|
self.first_prefix = first_prefix
|
|
self.prefix = prefix
|
|
|
|
# Text renders its `end` ("\n" by default), which would break lines
|
|
self.first_prefix.end = ""
|
|
self.prefix.end = ""
|
|
|
|
def __rich_console__(
|
|
self, console: Console, options: ConsoleOptions
|
|
) -> RenderResult:
|
|
prefix_width = max(self.first_prefix.cell_len, self.prefix.cell_len)
|
|
lines = console.render_lines(
|
|
self.renderable,
|
|
options.update_width(options.max_width - prefix_width),
|
|
pad=False,
|
|
)
|
|
|
|
new_line = Segment.line()
|
|
|
|
for first, line in loop_first(lines):
|
|
if any(segment.text.strip() for segment in line):
|
|
yield from console.render(
|
|
self.first_prefix if first else self.prefix, options
|
|
)
|
|
yield from line
|
|
|
|
yield new_line
|
|
|
|
|
|
class FastAPIStyle(BaseStyle):
|
|
"""Uniform left indent with emoji bullets hanging to the left of the text.
|
|
|
|
``emoji=`` renders as a bullet in a fixed column; ``bullet=False`` drops
|
|
the bullet column and just indents. This is a small, purpose-built subset
|
|
of the richer style in fastapi-cloud-cli.
|
|
"""
|
|
|
|
content_padding = 1
|
|
emoji_column_width = 3
|
|
|
|
def render_element(
|
|
self,
|
|
element: Any,
|
|
is_active: bool = False,
|
|
done: bool = False,
|
|
parent: Element | None = None,
|
|
**kwargs: Any,
|
|
) -> RenderableType:
|
|
rendered = super().render_element(
|
|
element=element, is_active=is_active, done=done, parent=parent, **kwargs
|
|
)
|
|
|
|
emoji = kwargs.get("emoji", "")
|
|
|
|
if not emoji and not kwargs.get("bullet", True):
|
|
indent = Text(" " * (self.content_padding + 1))
|
|
return IndentedBlock(rendered, first_prefix=indent, prefix=indent)
|
|
|
|
return IndentedBlock(
|
|
rendered,
|
|
first_prefix=self._get_bullet_prefix(emoji),
|
|
prefix=Text(" " * (self.content_padding + self.emoji_column_width)),
|
|
)
|
|
|
|
def _get_bullet_prefix(self, emoji: str) -> Text:
|
|
prefix = Text(" " * self.content_padding)
|
|
|
|
if emoji:
|
|
prefix.append_text(Text.from_markup(emoji))
|
|
|
|
prefix.pad_right(
|
|
self.content_padding + self.emoji_column_width - prefix.cell_len
|
|
)
|
|
|
|
return prefix
|
|
|
|
|
|
LOG_LEVEL_COLORS = {
|
|
"debug": "blue",
|
|
"info": "cyan",
|
|
"warning": "yellow",
|
|
"warn": "yellow",
|
|
"error": "red",
|
|
"critical": "magenta",
|
|
"fatal": "magenta",
|
|
}
|
|
|
|
|
|
def _get_log_bullet(level: str) -> str:
|
|
"""Colored bar rendered in the emoji bullet column, matching the log
|
|
level, like the ``fastapi cloud logs`` output."""
|
|
color = LOG_LEVEL_COLORS.get(level.lower(), "dim")
|
|
|
|
return f"[{color}]▕[/{color}]"
|
|
|
|
|
|
class CustomFormatter(DefaultFormatter):
|
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self.toolkit = get_rich_toolkit()
|
|
|
|
def formatMessage(self, record: logging.LogRecord) -> str:
|
|
message = record.getMessage()
|
|
result = self.toolkit.print_as_string(
|
|
message, emoji=_get_log_bullet(record.levelname)
|
|
)
|
|
# Prepend newline to fix alignment after ^C is printed by the terminal
|
|
if message == "Shutting down":
|
|
result = "\n" + result
|
|
return result
|
|
|
|
|
|
def get_uvicorn_log_config() -> dict[str, Any]:
|
|
return {
|
|
"version": 1,
|
|
"disable_existing_loggers": False,
|
|
"formatters": {
|
|
"default": {
|
|
"()": CustomFormatter,
|
|
"fmt": "%(levelprefix)s %(message)s",
|
|
"use_colors": None,
|
|
},
|
|
"access": {
|
|
"()": CustomFormatter,
|
|
"fmt": "%(levelprefix)s %(client_addr)s - '%(request_line)s' %(status_code)s",
|
|
},
|
|
},
|
|
"handlers": {
|
|
"default": {
|
|
"formatter": "default",
|
|
"class": "logging.StreamHandler",
|
|
"stream": "ext://sys.stderr",
|
|
},
|
|
"access": {
|
|
"formatter": "access",
|
|
"class": "logging.StreamHandler",
|
|
"stream": "ext://sys.stdout",
|
|
},
|
|
},
|
|
"loggers": {
|
|
"uvicorn": {
|
|
"handlers": ["default"],
|
|
"level": "INFO",
|
|
"propagate": False,
|
|
},
|
|
"uvicorn.error": {"level": "INFO"},
|
|
"uvicorn.access": {
|
|
"handlers": ["access"],
|
|
"level": "INFO",
|
|
"propagate": False,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def get_rich_toolkit() -> RichToolkit:
|
|
return RichToolkit(style=FastAPIStyle())
|