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}")