diff --git a/pyproject.toml b/pyproject.toml index d92ec1a0..99ec0a03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "isort~=5.12.0", "libcst~=1.1.0", "pylint~=3.0.0", + "python-json-logger~=2.0.0", "PyYAML~=6.0.0", "semgrep~=1.45.0", "wrapt~=1.15.0", diff --git a/src/codemodder/cli.py b/src/codemodder/cli.py index efc89fbb..dddb6841 100644 --- a/src/codemodder/cli.py +++ b/src/codemodder/cli.py @@ -139,9 +139,13 @@ def parse_args(argv, codemod_registry): "--log-format", type=OutputFormat, default=OutputFormat.HUMAN, - choices=[str(x).split(".")[-1].lower() for x in list(OutputFormat)], + choices=[OutputFormat.HUMAN, OutputFormat.JSON], help="the format for the log output", ) + parser.add_argument( + "--project-name", + help="optional descriptive name for the project used in log output", + ) parser.add_argument( "--path-exclude", action=CsvListAction, diff --git a/src/codemodder/codemodder.py b/src/codemodder/codemodder.py index b25bccee..a938e387 100644 --- a/src/codemodder/codemodder.py +++ b/src/codemodder/codemodder.py @@ -129,7 +129,7 @@ def run(original_args) -> int: ) return 1 - configure_logger(argv.verbose) + configure_logger(argv.verbose, argv.log_format, argv.project_name) log_section("startup") logger.info("codemodder: python/%s", __VERSION__) diff --git a/src/codemodder/logging.py b/src/codemodder/logging.py index 7a050d0d..1754a813 100644 --- a/src/codemodder/logging.py +++ b/src/codemodder/logging.py @@ -1,6 +1,9 @@ from enum import Enum import logging import sys +from typing import Optional + +from pythonjsonlogger import jsonlogger logger = logging.getLogger("codemodder") @@ -13,6 +16,28 @@ class OutputFormat(Enum): HUMAN = "human" JSON = "json" + def __str__(self): + """For rendering properly in argparse help.""" + return self.value.lower() + + +class CodemodderJsonFormatter(jsonlogger.JsonFormatter): + project_name: Optional[str] + + def __init__(self, *args, project_name: Optional[str] = None, **kwargs): + self.project_name = project_name + super().__init__(*args, **kwargs) + + def add_fields(self, log_record, record, message_dict): + super().add_fields(log_record, record, message_dict) + log_record["timestamp"] = log_record.pop("asctime") + log_record.move_to_end("timestamp", last=False) + log_record["level"] = record.levelname.upper() + log_record["file"] = record.filename + log_record["line"] = record.lineno + if self.project_name: + log_record["project-name"] = self.project_name + def log_section(section_name: str): """ @@ -30,22 +55,33 @@ def log_list(level: int, header: str, items: list, predicate=None): logger.log(level, " - %s", predicate(item) if predicate else item) -def configure_logger(verbose: bool): +def configure_logger( + verbose: bool, log_format: OutputFormat, project_name: Optional[str] = None +): """ Configure the logger based on the verbosity level. """ log_level = logging.DEBUG if verbose else logging.INFO - # TODO: this should all be conditional on the output format stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setLevel(log_level) - stdout_handler.addFilter(lambda record: record.levelno <= logging.WARNING) + handlers = [stdout_handler] - stderr_handler = logging.StreamHandler(sys.stderr) - stderr_handler.setLevel(logging.ERROR) + match log_format: + case OutputFormat.HUMAN: + stdout_handler.addFilter(lambda record: record.levelno <= logging.WARNING) + stderr_handler = logging.StreamHandler(sys.stderr) + stderr_handler.setLevel(logging.ERROR) + handlers.append(stderr_handler) + case OutputFormat.JSON: + formatter = CodemodderJsonFormatter( + "%(asctime) %(level) %(message) %(file) %(line)", + project_name=project_name, + ) + stdout_handler.setFormatter(formatter) logging.basicConfig( format="%(message)s", level=log_level, - handlers=[stdout_handler, stderr_handler], + handlers=handlers, ) diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 00000000..79d678ff --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,18 @@ +from pythonjsonlogger import jsonlogger + +from codemodder.logging import OutputFormat, configure_logger + + +def test_json_logger(mocker): + basic_config = mocker.patch("logging.basicConfig") + configure_logger(False, OutputFormat.JSON, "test-project") + assert basic_config.call_count == 1 + assert basic_config.call_args[1]["format"] == "%(message)s" + assert isinstance( + basic_config.call_args[1]["handlers"][0].formatter, + jsonlogger.JsonFormatter, + ) + assert ( + basic_config.call_args[1]["handlers"][0].formatter.project_name + == "test-project" + )