Spaces:
Running on Zero
Running on Zero
File size: 5,276 Bytes
d92710f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | """Shared structured logger for every CellTriage module.
WHAT: ``get_logger(name)`` returns a configured logger that writes to both the
console and ``outputs/logs/<name>.log``.
WHY: Two project rules depend on durable logs rather than console scrollback.
Every excluded cell must be recorded with its ID and the reason it was dropped,
and every reported number must be traceable to a generated artifact. A run
whose diagnostics vanished with the terminal session cannot support either
claim months later during review.
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from src.utils.paths import LOG_DIR, relative_to_root
#: Timestamped, level-tagged, module-tagged. The line number is included
#: because a QC audit trail is only useful if a surprising log line can be
#: traced back to the exact code that emitted it.
_LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s"
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
DEFAULT_FILE_LEVEL = logging.DEBUG
DEFAULT_CONSOLE_LEVEL = logging.INFO
def _log_path_for(name: str) -> Path:
"""Map a logger name to its log file, flattening dotted module paths.
``src.data.mat_parser`` -> ``outputs/logs/mat_parser.log`` so that the log
directory stays readable and one file corresponds to one module.
"""
stem = name.split(".")[-1] if name else "celltriage"
return LOG_DIR / f"{stem}.log"
def get_logger(
name: str,
*,
file_level: int = DEFAULT_FILE_LEVEL,
console_level: int = DEFAULT_CONSOLE_LEVEL,
log_dir: Path | None = None,
) -> logging.Logger:
"""Return a logger writing to the console and to ``outputs/logs/<name>.log``.
Idempotent: calling this repeatedly with the same ``name`` returns the same
logger without stacking duplicate handlers. WHY that matters: modules import
each other freely, and duplicated handlers produce duplicated log lines,
which makes a count of "cells excluded" read wrong by an integer factor.
Args:
name: Logger name, conventionally ``__name__`` of the calling module.
file_level: Threshold for the file handler. DEBUG by default, because
the file is the audit trail and disk is cheap.
console_level: Threshold for the console handler. INFO by default to
keep interactive runs readable.
log_dir: Override for the log directory. Intended for tests.
Returns:
A configured :class:`logging.Logger`.
"""
logger = logging.getLogger(name)
# The logger's own level must be the more permissive of the two handler
# levels, otherwise records are dropped before any handler sees them.
logger.setLevel(min(file_level, console_level))
# Do not propagate to the root logger: a library or notebook that has
# configured root logging would otherwise duplicate every record.
logger.propagate = False
if getattr(logger, "_celltriage_configured", False):
return logger
target_dir = Path(log_dir) if log_dir is not None else LOG_DIR
target_dir.mkdir(parents=True, exist_ok=True)
log_file = target_dir / _log_path_for(name).name
formatter = logging.Formatter(fmt=_LOG_FORMAT, datefmt=_DATE_FORMAT)
# Append rather than truncate: a re-run must not destroy the record of the
# run that preceded it.
file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
file_handler.setLevel(file_level)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
console_handler = logging.StreamHandler(stream=sys.stdout)
console_handler.setLevel(console_level)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
logger._celltriage_configured = True # type: ignore[attr-defined]
logger.debug("Logger initialised; writing to %s", relative_to_root(log_file))
return logger
def log_section(logger: logging.Logger, title: str, width: int = 78) -> None:
"""Emit a visually distinct section banner.
WHY: Phase summaries and reconciliation tables must be findable by eye in a
long log file.
"""
logger.info("=" * width)
logger.info(title)
logger.info("=" * width)
def run_logger_smoke_test() -> Path:
"""Write one record at every level and return the resulting log file path.
WHY this exists as a callable entry point: "logging works" is a claim best
demonstrated by an executed artifact on disk rather than asserted, and a
misconfigured handler is otherwise only discovered when a real run needs it.
"""
logger = get_logger("logger_smoke_test")
log_section(logger, "CellTriage logger smoke test")
logger.debug("DEBUG record - visible in the file, suppressed on console.")
logger.info("INFO record - the default console level.")
logger.warning("WARNING record.")
logger.error("ERROR record.")
log_file = LOG_DIR / "logger_smoke_test.log"
logger.info("Smoke test complete; log file at %s", relative_to_root(log_file))
return log_file
if __name__ == "__main__":
path = run_logger_smoke_test()
print(f"Logger smoke test wrote: {path}")
|