Rebrand the Rust client crate (coven/ → hh/, package+binary "hack-house"), README, CLI strings, and branch (coven → hack-house). Gitea repo renamed cmd-chat → hack-house to match. Crypto/server logic unchanged; selftest + golden-vector test still green, binary is now `hack-house`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
388 lines
12 KiB
Python
388 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
|
|
from sanic.helpers import is_atty, json_dumps
|
|
from sanic.logging.color import LEVEL_COLORS
|
|
from sanic.logging.color import Colors as c
|
|
|
|
|
|
CONTROL_RE = re.compile(r"\033\[[0-9;]*\w")
|
|
CONTROL_LIMIT_IDENT = "\033[1000D\033[{limit}C"
|
|
CONTROL_LIMIT_START = "\033[1000D\033[{start}C\033[K"
|
|
CONTROL_LIMIT_END = "\033[1000C\033[{right}D\033[K"
|
|
EXCEPTION_LINE_RE = re.compile(r"^(?P<exc>.*?): (?P<message>.*)$")
|
|
FILE_LINE_RE = re.compile(
|
|
r"File \"(?P<path>.*?)\", line (?P<line_num>\d+), in (?P<location>.*)"
|
|
)
|
|
DEFAULT_FIELDS = set(
|
|
logging.LogRecord("", 0, "", 0, "", (), None).__dict__.keys()
|
|
) | {
|
|
"ident",
|
|
"message",
|
|
"asctime",
|
|
"right",
|
|
}
|
|
|
|
|
|
class AutoFormatter(logging.Formatter):
|
|
"""
|
|
Automatically sets up the formatter based on the environment.
|
|
|
|
It will switch between the Debug and Production formatters based upon
|
|
how the environment is set up. Additionally, it will automatically
|
|
detect if the output is a TTY and colorize the output accordingly.
|
|
"""
|
|
|
|
SETUP = False
|
|
ATTY = is_atty()
|
|
NO_COLOR = os.environ.get("SANIC_NO_COLOR", "false").lower() == "true"
|
|
LOG_EXTRA = os.environ.get("SANIC_LOG_EXTRA", "true").lower() == "true"
|
|
IDENT = os.environ.get("SANIC_WORKER_IDENTIFIER", "Main ") or "Main "
|
|
DATE_FORMAT = "%Y-%m-%d %H:%M:%S %z"
|
|
IDENT_LIMIT = 5
|
|
MESSAGE_START = 42
|
|
PREFIX_FORMAT = (
|
|
f"{c.GREY}%(ident)s{{limit}} %(asctime)s {c.END}"
|
|
"%(levelname)s: {start}"
|
|
)
|
|
MESSAGE_FORMAT = "%(message)s"
|
|
|
|
def __init__(self, *args) -> None:
|
|
args_list = list(args)
|
|
if not args:
|
|
args_list.append(self._make_format())
|
|
elif args and not args[0]:
|
|
args_list[0] = self._make_format()
|
|
if len(args_list) < 2:
|
|
args_list.append(self.DATE_FORMAT)
|
|
elif not args[1]:
|
|
args_list[1] = self.DATE_FORMAT
|
|
|
|
super().__init__(*args_list)
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
record.ident = self.IDENT
|
|
self._set_levelname(record)
|
|
output = super().format(record)
|
|
if self.LOG_EXTRA:
|
|
output += self._log_extra(record)
|
|
return output
|
|
|
|
def _set_levelname(self, record: logging.LogRecord) -> None:
|
|
if (
|
|
self.ATTY
|
|
and not self.NO_COLOR
|
|
and (color := LEVEL_COLORS.get(record.levelno))
|
|
):
|
|
record.levelname = f"{color}{record.levelname}{c.END}"
|
|
|
|
def _make_format(self) -> str:
|
|
limit = CONTROL_LIMIT_IDENT.format(limit=self.IDENT_LIMIT)
|
|
start = CONTROL_LIMIT_START.format(start=self.MESSAGE_START)
|
|
base_format = self.PREFIX_FORMAT + self.MESSAGE_FORMAT
|
|
fmt = base_format.format(limit=limit, start=start)
|
|
if not self.ATTY or self.NO_COLOR:
|
|
return CONTROL_RE.sub("", fmt)
|
|
return fmt
|
|
|
|
def _log_extra(self, record: logging.LogRecord, indent: int = 0) -> str:
|
|
extra_lines = [""]
|
|
|
|
for key, value in record.__dict__.items():
|
|
if key not in DEFAULT_FIELDS:
|
|
extra_lines.append(self._format_key_value(key, value, indent))
|
|
|
|
return "\n".join(extra_lines)
|
|
|
|
def _format_key_value(self, key, value, indent):
|
|
indentation = " " * indent
|
|
template = (
|
|
f"{indentation} {{c.YELLOW}}{{key}}{{c.END}}={{value}}"
|
|
if self.ATTY and not self.NO_COLOR
|
|
else f"{indentation}{{key}}={{value}}"
|
|
)
|
|
if isinstance(value, dict):
|
|
nested_lines = [template.format(c=c, key=key, value="")]
|
|
for nested_key, nested_value in value.items():
|
|
nested_lines.append(
|
|
self._format_key_value(
|
|
nested_key, nested_value, indent + 2
|
|
)
|
|
)
|
|
return "\n".join(nested_lines)
|
|
else:
|
|
return template.format(c=c, key=key, value=value)
|
|
|
|
|
|
class DebugFormatter(AutoFormatter):
|
|
"""
|
|
The DebugFormatter is used for development and debugging purposes.
|
|
|
|
It can be used directly, or it will be automatically selected if the
|
|
environment is set up for development and is using the AutoFormatter.
|
|
"""
|
|
|
|
IDENT_LIMIT = 5
|
|
MESSAGE_START = 23
|
|
DATE_FORMAT = "%H:%M:%S"
|
|
|
|
def _set_levelname(self, record: logging.LogRecord) -> None:
|
|
if len(record.levelname) > 5:
|
|
record.levelname = record.levelname[:4]
|
|
super()._set_levelname(record)
|
|
|
|
def formatException(self, ei): # no cov
|
|
orig = super().formatException(ei)
|
|
if not self.ATTY or self.NO_COLOR:
|
|
return orig
|
|
colored_traceback = []
|
|
lines = orig.splitlines()
|
|
for idx, line in enumerate(lines):
|
|
if line.startswith(" File"):
|
|
line = self._color_file_line(line)
|
|
elif line.startswith(" "):
|
|
line = self._color_code_line(line)
|
|
elif (
|
|
"Error" in line or "Exception" in line or len(lines) - 1 == idx
|
|
):
|
|
line = self._color_exception_line(line)
|
|
colored_traceback.append(line)
|
|
return "\n".join(colored_traceback)
|
|
|
|
def _color_exception_line(self, line: str) -> str: # no cov
|
|
match = EXCEPTION_LINE_RE.match(line)
|
|
if not match:
|
|
return line
|
|
exc = match.group("exc")
|
|
message = match.group("message")
|
|
return f"{c.SANIC}{c.BOLD}{exc}{c.END}: {c.BOLD}{message}{c.END}"
|
|
|
|
def _color_file_line(self, line: str) -> str: # no cov
|
|
match = FILE_LINE_RE.search(line)
|
|
if not match:
|
|
return line
|
|
path = match.group("path")
|
|
line_num = match.group("line_num")
|
|
location = match.group("location")
|
|
return (
|
|
f' File "{path}", line {c.CYAN}{c.BOLD}{line_num}{c.END}, '
|
|
f"in {c.BLUE}{c.BOLD}{location}{c.END}"
|
|
)
|
|
|
|
def _color_code_line(self, line: str) -> str: # no cov
|
|
return f"{c.YELLOW}{line}{c.END}"
|
|
|
|
|
|
class ProdFormatter(AutoFormatter):
|
|
"""
|
|
The ProdFormatter is used for production environments.
|
|
|
|
It can be used directly, or it will be automatically selected if the
|
|
environment is set up for production and is using the AutoFormatter.
|
|
"""
|
|
|
|
|
|
class LegacyFormatter(AutoFormatter):
|
|
"""
|
|
The LegacyFormatter is used if you want to use the old style of logging.
|
|
|
|
You can use it as follows, typically in conjunction with the
|
|
LegacyAccessFormatter:
|
|
|
|
.. code-block:: python
|
|
|
|
from sanic.log import LOGGING_CONFIG_DEFAULTS
|
|
|
|
LOGGING_CONFIG_DEFAULTS["formatters"] = {
|
|
"generic": {
|
|
"class": "sanic.logging.formatter.LegacyFormatter"
|
|
},
|
|
"access": {
|
|
"class": "sanic.logging.formatter.LegacyAccessFormatter"
|
|
},
|
|
}
|
|
"""
|
|
|
|
PREFIX_FORMAT = "%(asctime)s [%(process)s] [%(levelname)s] "
|
|
DATE_FORMAT = "[%Y-%m-%d %H:%M:%S %z]"
|
|
|
|
|
|
class AutoAccessFormatter(AutoFormatter):
|
|
MESSAGE_FORMAT = (
|
|
f"{c.PURPLE}%(host)s "
|
|
f"{c.BLUE + c.BOLD}%(request)s{c.END} "
|
|
f"%(right)s%(status)s %(byte)s {c.GREY}%(duration)s{c.END}"
|
|
)
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
status = len(str(getattr(record, "status", "")))
|
|
byte = len(str(getattr(record, "byte", "")))
|
|
duration = len(str(getattr(record, "duration", "")))
|
|
record.right = (
|
|
CONTROL_LIMIT_END.format(right=status + byte + duration + 1)
|
|
if self.ATTY
|
|
else ""
|
|
)
|
|
return super().format(record)
|
|
|
|
def _set_levelname(self, record: logging.LogRecord) -> None:
|
|
if self.ATTY and record.levelno == logging.INFO:
|
|
record.levelname = f"{c.SANIC}ACCESS{c.END}"
|
|
|
|
|
|
class LegacyAccessFormatter(AutoAccessFormatter):
|
|
"""
|
|
The LegacyFormatter is used if you want to use the old style of logging.
|
|
|
|
You can use it as follows, typically in conjunction with the
|
|
LegacyFormatter:
|
|
|
|
.. code-block:: python
|
|
|
|
from sanic.log import LOGGING_CONFIG_DEFAULTS
|
|
|
|
LOGGING_CONFIG_DEFAULTS["formatters"] = {
|
|
"generic": {
|
|
"class": "sanic.logging.formatter.LegacyFormatter"
|
|
},
|
|
"access": {
|
|
"class": "sanic.logging.formatter.LegacyAccessFormatter"
|
|
},
|
|
}
|
|
"""
|
|
|
|
PREFIX_FORMAT = "%(asctime)s - (%(name)s)[%(levelname)s][%(host)s]: "
|
|
MESSAGE_FORMAT = "%(request)s %(message)s %(status)s %(byte)s"
|
|
|
|
|
|
class DebugAccessFormatter(AutoAccessFormatter):
|
|
IDENT_LIMIT = 5
|
|
MESSAGE_START = 23
|
|
DATE_FORMAT = "%H:%M:%S"
|
|
LOG_EXTRA = False
|
|
|
|
|
|
class ProdAccessFormatter(AutoAccessFormatter):
|
|
IDENT_LIMIT = 5
|
|
MESSAGE_START = 42
|
|
PREFIX_FORMAT = (
|
|
f"{c.GREY}%(ident)s{{limit}}|%(asctime)s{c.END} "
|
|
f"%(levelname)s: {{start}}"
|
|
)
|
|
MESSAGE_FORMAT = (
|
|
f"{c.PURPLE}%(host)s {c.BLUE + c.BOLD}"
|
|
f"%(request)s{c.END} "
|
|
f"%(right)s%(status)s %(byte)s {c.GREY}%(duration)s{c.END}"
|
|
)
|
|
LOG_EXTRA = False
|
|
|
|
|
|
class JSONFormatter(AutoFormatter):
|
|
"""
|
|
The JSONFormatter is used to output logs in JSON format.
|
|
|
|
This is useful for logging to a file or to a log aggregator that
|
|
understands JSON. It will output all the fields from the LogRecord
|
|
as well as the extra fields that are passed in.
|
|
|
|
You can use it as follows:
|
|
|
|
.. code-block:: python
|
|
|
|
from sanic.log import LOGGING_CONFIG_DEFAULTS
|
|
|
|
LOGGING_CONFIG_DEFAULTS["formatters"] = {
|
|
"generic": {
|
|
"class": "sanic.logging.formatter.JSONFormatter"
|
|
},
|
|
"access": {
|
|
"class": "sanic.logging.formatter.JSONFormatter"
|
|
},
|
|
}
|
|
"""
|
|
|
|
ATTY = False
|
|
NO_COLOR = True
|
|
FIELDS = [
|
|
"name",
|
|
"levelno",
|
|
"pathname",
|
|
"module",
|
|
"filename",
|
|
"lineno",
|
|
]
|
|
|
|
dumps = json_dumps
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
return self.format_dict(self.to_dict(record))
|
|
|
|
def to_dict(self, record: logging.LogRecord) -> dict:
|
|
base = {field: getattr(record, field, None) for field in self.FIELDS}
|
|
extra = {
|
|
key: value
|
|
for key, value in record.__dict__.items()
|
|
if key not in DEFAULT_FIELDS
|
|
}
|
|
info = {}
|
|
if record.exc_info:
|
|
info["exc_info"] = self.formatException(record.exc_info)
|
|
if record.stack_info:
|
|
info["stack_info"] = self.formatStack(record.stack_info)
|
|
return {
|
|
"timestamp": self.formatTime(record, self.datefmt),
|
|
"level": record.levelname,
|
|
"message": record.getMessage(),
|
|
**base,
|
|
**info,
|
|
**extra,
|
|
}
|
|
|
|
def format_dict(self, record: dict) -> str:
|
|
return self.dumps(record)
|
|
|
|
|
|
class JSONAccessFormatter(JSONFormatter):
|
|
"""
|
|
The JSONAccessFormatter is used to output access logs in JSON format.
|
|
|
|
This is useful for logging to a file or to a log aggregator that
|
|
understands JSON. It will output all the fields from the LogRecord
|
|
as well as the extra fields that are passed in.
|
|
|
|
You can use it as follows:
|
|
|
|
.. code-block:: python
|
|
|
|
from sanic.log import LOGGING_CONFIG_DEFAULTS
|
|
|
|
LOGGING_CONFIG_DEFAULTS["formatters"] = {
|
|
"generic": {
|
|
"class": "sanic.logging.formatter.JSONFormatter"
|
|
},
|
|
"access": {
|
|
"class": "sanic.logging.formatter.JSONAccessFormatter"
|
|
},
|
|
}
|
|
"""
|
|
|
|
FIELDS = [
|
|
"host",
|
|
"request",
|
|
"status",
|
|
"byte",
|
|
"duration",
|
|
]
|
|
|
|
def to_dict(self, record: logging.LogRecord) -> dict:
|
|
base = {field: getattr(record, field, None) for field in self.FIELDS}
|
|
return {
|
|
"timestamp": self.formatTime(record, self.datefmt),
|
|
"level": record.levelname,
|
|
"message": record.getMessage(),
|
|
**base,
|
|
}
|