From 8f0688c3d2fbcaafe61a7d44cc32a9251d4e7a5f Mon Sep 17 00:00:00 2001 From: Tristan Menzel Date: Tue, 3 Dec 2024 17:56:38 -0800 Subject: [PATCH] feat(internal): Add option to for puya to output logs in json format rather than pretty printed --- src/puya/__main__.py | 12 +++++-- src/puya/log.py | 85 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/puya/__main__.py b/src/puya/__main__.py index 65724b2869..4e78728703 100644 --- a/src/puya/__main__.py +++ b/src/puya/__main__.py @@ -4,7 +4,7 @@ import attrs -from puya.log import LogLevel, configure_logging +from puya.log import LogFormat, LogLevel, configure_logging from puya.main import main @@ -14,6 +14,7 @@ class _PuyaCLIArgs: awst: Path | None = None source_annotations: Path | None = None log_level: LogLevel = LogLevel.info + log_format: LogFormat = LogFormat.default def cli() -> None: @@ -25,12 +26,19 @@ def cli() -> None: parser.add_argument( "--log-level", type=LogLevel.from_string, choices=list(LogLevel), default=LogLevel.info ) + parser.add_argument( + "--log-format", + type=LogFormat.from_string, + choices=list(LogFormat), + default=LogFormat.default, + ) parser.add_argument("--options", type=Path, required=True) parser.add_argument("--awst", type=Path, required=True) parser.add_argument("--source-annotations", type=Path) parsed_args = _PuyaCLIArgs() parser.parse_args(namespace=parsed_args) - configure_logging(min_log_level=parsed_args.log_level) + + configure_logging(min_log_level=parsed_args.log_level, log_format=parsed_args.log_format) assert parsed_args.options options_json = parsed_args.options.read_text("utf8") diff --git a/src/puya/log.py b/src/puya/log.py index 2391e3fd29..6e368c7eff 100644 --- a/src/puya/log.py +++ b/src/puya/log.py @@ -1,4 +1,5 @@ import contextlib +import json import logging import os.path import sys @@ -6,7 +7,7 @@ from collections import Counter from collections.abc import Iterator, Mapping, Sequence from contextvars import ContextVar -from enum import IntEnum +from enum import IntEnum, StrEnum, auto from io import StringIO from pathlib import Path @@ -16,6 +17,18 @@ from puya.parse import SourceLocation +class LogFormat(StrEnum): + default = auto() + json = auto() + + @staticmethod + def from_string(s: str) -> "LogFormat": + try: + return LogFormat[s] + except KeyError as err: + raise ValueError from err + + class LogLevel(IntEnum): notset = logging.NOTSET debug = logging.DEBUG @@ -74,11 +87,59 @@ def exit_if_errors(self) -> None: _current_ctx: ContextVar[LoggingContext] = ContextVar("current_ctx") +class PuyaJsonRender(structlog.dev.ConsoleRenderer): + def __init__(self, *, base_path: str) -> None: + super().__init__(colors=False) + self.base_path = base_path + os.path.sep + + def _location_json( + self, location: SourceLocation | None + ) -> Mapping[str, str | int | None] | None: + if not location or not location.file: + return None + + file = str(location.file) + if file.startswith(self.base_path): + file = file[len(self.base_path) :] + + return { + "file": file, + "line": location.line, + "end_line": location.end_line, + "column": location.column, + "end_column": location.end_column, + } + + def __call__( + self, + _logger: structlog.typing.WrappedLogger, + _name: str, + event_dict: structlog.typing.EventDict, + ) -> str: + # force event to str for compatibility with standard library + event = event_dict.pop("event", None) + if not isinstance(event, str): + event = str(event) + + important: bool = event_dict.pop("important", False) + location: SourceLocation | None = event_dict.pop("location", None) + level = event_dict.pop("level", "info") + + return json.dumps( + { + "level": level, + "location": self._location_json(location), + "message": event, + "important": important, + } + ) + + class PuyaConsoleRender(structlog.dev.ConsoleRenderer): - def __init__(self, *, colors: bool): + def __init__(self, *, colors: bool, base_path: str): super().__init__(colors=colors) self.level_to_color = self.get_default_level_styles(colors) - self.base_path = str(Path.cwd()) # TODO: don't assume this? + self.base_path = base_path if not self.base_path.endswith( os.path.sep ): # TODO: can we always append the path seperator? @@ -213,17 +274,31 @@ def __call__( def configure_logging( - *, min_log_level: LogLevel = LogLevel.notset, cache_logger: bool = True + *, + min_log_level: LogLevel = LogLevel.notset, + cache_logger: bool = True, + log_format: LogFormat = LogFormat.default, ) -> None: if cache_logger and structlog.is_configured(): raise ValueError( "Logging can not be configured more than once if using cache_logger = True" ) + base_path = str(Path.cwd()) # TODO: don't assume this? + match log_format: + case LogFormat.json: + log_renderer: structlog.typing.Processor = PuyaJsonRender(base_path=base_path) + case LogFormat.default: + log_renderer = PuyaConsoleRender( + colors="NO_COLOR" not in os.environ, base_path=base_path + ) + case never: + typing.assert_never(never) + processors: list[structlog.typing.Processor] = [ structlog.contextvars.merge_contextvars, structlog.processors.add_log_level, structlog.processors.StackInfoRenderer(), - PuyaConsoleRender(colors="NO_COLOR" not in os.environ), + log_renderer, ] if min_log_level != LogLevel.notset: # filtering via a processor instead of via the logger like