Files
8bfb8468b4 feat(compiler): add centralized structured logging (#6) (#50)
Implement structured, machine-readable logging per MASTER_SPEC §22, §25.7 in
compiler/common/log.py.

- DebugLevel IntEnum with the four levels (None/Basic/Verbose/Developer),
  ordered; DebugLevel.from_config maps a debug config section to a threshold
  (disabled -> None).
- LogRecord: immutable structured record (level, event, fields) with a
  machine-readable to_json; no timestamps, so logging stays deterministic.
- StructuredLogger: filters by threshold, emits to an injectable sink, and
  offers basic/verbose/developer helpers plus from_config. No global state.
- json_lines_sink writes one JSON object per line to a stream (never print).
- Tighten configuration.schema.json debug.level to enum
  none/basic/verbose/developer.

Tests: level filtering across all four levels, structured/immutable records,
config integration, and a guard asserting no print() exists in the compiler
package.

Co-authored-by: kunail0804 <kunail0804@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:17:00 +02:00

126 lines
4.3 KiB
Python

"""Centralized structured logging for the compiler (MASTER_SPEC §22, §25.7).
Logs are structured :class:`LogRecord` objects — never free-form strings — so
they can be consumed programmatically without parsing. Emission is filtered by a
:class:`DebugLevel` threshold driven by configuration (``config.debug``), and the
compiler never calls ``print``.
Records carry no timestamp so that logging stays deterministic and testable. The
output destination is an injectable ``sink`` callable; the ComfyUI nodes (or a
test) supply one. :func:`json_lines_sink` provides a ready-made sink that writes
one JSON object per line to a stream.
"""
from __future__ import annotations
import json
import sys
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from enum import IntEnum
from types import MappingProxyType
from typing import IO, Any
_EMPTY_FIELDS: Mapping[str, Any] = MappingProxyType({})
class DebugLevel(IntEnum):
"""Debug verbosity levels (§22.1), ordered from least to most verbose."""
NONE = 0
BASIC = 1
VERBOSE = 2
DEVELOPER = 3
@classmethod
def from_config(cls, *, enabled: bool, level: str) -> DebugLevel:
"""Resolve the active level from a ``debug`` config section.
When debugging is disabled the level is always :attr:`NONE`, regardless
of the configured ``level``.
Raises:
ValueError: If ``level`` is not a known level name.
"""
if not enabled:
return cls.NONE
try:
return cls[level.upper()]
except KeyError:
raise ValueError(f"Unknown debug level '{level}'.") from None
@dataclass(frozen=True)
class LogRecord:
"""A single structured log record."""
level: DebugLevel
event: str
fields: Mapping[str, Any] = field(default_factory=lambda: _EMPTY_FIELDS)
def __post_init__(self) -> None:
if not isinstance(self.fields, MappingProxyType):
frozen = MappingProxyType(dict(self.fields)) if self.fields else _EMPTY_FIELDS
object.__setattr__(self, "fields", frozen)
def to_json(self) -> dict[str, Any]:
return {
"level": self.level.name.lower(),
"event": self.event,
"fields": dict(self.fields),
}
class StructuredLogger:
"""Emits structured log records filtered by a configured :class:`DebugLevel`.
A record is emitted only when its level is not :attr:`DebugLevel.NONE` and is
at or below the logger's threshold. Emission goes to the injected ``sink``;
without a sink the logger is silent but still returns the (unemitted) record.
"""
def __init__(
self,
level: DebugLevel = DebugLevel.NONE,
sink: Callable[[LogRecord], None] | None = None,
) -> None:
self.level = level
self._sink = sink
@classmethod
def from_config(cls, config: Any, sink: Callable[[LogRecord], None] | None = None):
"""Build a logger from a Config's ``debug`` section."""
level = DebugLevel.from_config(enabled=config.debug.enabled, level=config.debug.level)
return cls(level=level, sink=sink)
def log(self, level: DebugLevel, event: str, **fields: Any) -> LogRecord | None:
"""Emit a record at ``level``; return it, or None if filtered out."""
if level == DebugLevel.NONE or level > self.level:
return None
record = LogRecord(level=level, event=event, fields=fields)
if self._sink is not None:
self._sink(record)
return record
def basic(self, event: str, **fields: Any) -> LogRecord | None:
return self.log(DebugLevel.BASIC, event, **fields)
def verbose(self, event: str, **fields: Any) -> LogRecord | None:
return self.log(DebugLevel.VERBOSE, event, **fields)
def developer(self, event: str, **fields: Any) -> LogRecord | None:
return self.log(DebugLevel.DEVELOPER, event, **fields)
def json_lines_sink(stream: IO[str] | None = None) -> Callable[[LogRecord], None]:
"""Return a sink that writes each record as one JSON line to ``stream``.
Defaults to ``sys.stderr``. Uses ``json.dumps`` (never ``print``).
"""
target = stream if stream is not None else sys.stderr
def _sink(record: LogRecord) -> None:
target.write(json.dumps(record.to_json(), sort_keys=True) + "\n")
return _sink