Source code for pymod.logging

"""Convenience helpers for pymod's logging.

The library is silent by default — both `pymod` and `pymod.wire` start
with `NullHandler` attached, so library users pay nothing unless they opt
in.

This module provides a one-line `configure()` for users who want quick
visibility, plus a `JsonFormatter` for structured output. Power users who
already have a logging setup can simply attach their own handlers to the
`pymod` and `pymod.wire` loggers and skip this module entirely.

Examples
--------
::

    import pymod.logging
    pymod.logging.configure(level="INFO")                         # human
    pymod.logging.configure(level="DEBUG", json=True)             # JSON
    pymod.logging.configure(level="INFO", wire_trace=True)        # + hex dumps
"""

from __future__ import annotations

import json as _json
import logging
import sys
from typing import IO, Any, Literal, Union

LogLevel = Union[int, Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]]

# stdlib LogRecord attributes that we don't want to copy verbatim into a
# JSON record. Anything outside this set is treated as user-supplied extra.
_RESERVED_LOGRECORD_FIELDS = frozenset({
    "args",
    "asctime",
    "created",
    "exc_info",
    "exc_text",
    "filename",
    "funcName",
    "levelname",
    "levelno",
    "lineno",
    "message",
    "module",
    "msecs",
    "msg",
    "name",
    "pathname",
    "process",
    "processName",
    "relativeCreated",
    "stack_info",
    "taskName",
    "thread",
    "threadName",
})


[docs] class JsonFormatter(logging.Formatter): """One log record per line, JSON-encoded. Suitable for log aggregators."""
[docs] def format(self, record: logging.LogRecord) -> str: payload: dict[str, Any] = { "timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"), "level": record.levelname, "logger": record.name, "message": record.getMessage(), } if record.exc_info: payload["exception"] = self.formatException(record.exc_info) for key, value in record.__dict__.items(): if key in _RESERVED_LOGRECORD_FIELDS or key.startswith("_"): continue try: _json.dumps(value) payload[key] = value except (TypeError, ValueError): payload[key] = repr(value) return _json.dumps(payload)
def _attach_handler( logger: logging.Logger, handler: logging.Handler, ) -> None: # Drop the NullHandler installed by `pymod.__init__`; install our own # only if no real handler is already attached. logger.handlers = [ h for h in logger.handlers if not isinstance(h, logging.NullHandler) ] if not any( isinstance(h, logging.StreamHandler) and h is not handler for h in logger.handlers ): logger.addHandler(handler)
[docs] def configure( *, level: LogLevel = "INFO", json: bool = False, wire_trace: bool = False, stream: IO[str] | None = None, ) -> None: """Attach a handler to the `pymod` logger. Parameters ---------- level : log level for the main `pymod` logger. json : emit structured JSON instead of human-readable lines. wire_trace : also enable the `pymod.wire` logger at DEBUG (hex dumps of every PDU sent and received). stream : destination; defaults to ``sys.stderr``. """ handler = logging.StreamHandler(stream or sys.stderr) if json: handler.setFormatter(JsonFormatter()) else: handler.setFormatter( logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s") ) pymod_logger = logging.getLogger("pymod") _attach_handler(pymod_logger, handler) pymod_logger.setLevel(level) if wire_trace: wire_logger = logging.getLogger("pymod.wire") _attach_handler(wire_logger, handler) wire_logger.setLevel(logging.DEBUG)