fix(core): apply level + propagate every call; tolerate pre-attached handlers
Browse files- src/core/logger.py +15 -8
- tests/core/test_logger.py +30 -0
src/core/logger.py
CHANGED
|
@@ -16,8 +16,14 @@ _DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
|
|
| 16 |
def get_logger(name: str, level: int = logging.INFO) -> logging.Logger:
|
| 17 |
"""Return a process-wide singleton logger for the given name.
|
| 18 |
|
| 19 |
-
Idempotent: repeated calls with the same name
|
| 20 |
-
instance and never stack duplicate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
Args:
|
| 23 |
name: Dotted logger name, conventionally `__name__`.
|
|
@@ -27,12 +33,13 @@ def get_logger(name: str, level: int = logging.INFO) -> logging.Logger:
|
|
| 27 |
Configured `logging.Logger` writing to stdout.
|
| 28 |
"""
|
| 29 |
logger = logging.getLogger(name)
|
| 30 |
-
if logger.handlers:
|
| 31 |
-
return logger
|
| 32 |
-
|
| 33 |
-
handler = logging.StreamHandler(stream=sys.stdout)
|
| 34 |
-
handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT))
|
| 35 |
-
logger.addHandler(handler)
|
| 36 |
logger.setLevel(level)
|
| 37 |
logger.propagate = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
return logger
|
|
|
|
| 16 |
def get_logger(name: str, level: int = logging.INFO) -> logging.Logger:
|
| 17 |
"""Return a process-wide singleton logger for the given name.
|
| 18 |
|
| 19 |
+
Idempotent on handler attachment: repeated calls with the same name
|
| 20 |
+
return the same Logger instance and never stack duplicate stdout
|
| 21 |
+
StreamHandlers. The most recent call wins on `level`, so callers can
|
| 22 |
+
raise/lower verbosity at runtime without rebuilding the logger.
|
| 23 |
+
|
| 24 |
+
Note on `propagate=False`: records do NOT bubble up to the root logger.
|
| 25 |
+
If a framework (FastAPI, Uvicorn, MLflow) needs to capture records via
|
| 26 |
+
a root handler in week-2 work, this default will need to be revisited.
|
| 27 |
|
| 28 |
Args:
|
| 29 |
name: Dotted logger name, conventionally `__name__`.
|
|
|
|
| 33 |
Configured `logging.Logger` writing to stdout.
|
| 34 |
"""
|
| 35 |
logger = logging.getLogger(name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
logger.setLevel(level)
|
| 37 |
logger.propagate = False
|
| 38 |
+
if not any(
|
| 39 |
+
isinstance(h, logging.StreamHandler) and h.stream is sys.stdout
|
| 40 |
+
for h in logger.handlers
|
| 41 |
+
):
|
| 42 |
+
handler = logging.StreamHandler(stream=sys.stdout)
|
| 43 |
+
handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT))
|
| 44 |
+
logger.addHandler(handler)
|
| 45 |
return logger
|
tests/core/test_logger.py
CHANGED
|
@@ -62,3 +62,33 @@ def test_get_logger_format_includes_level_and_name() -> None:
|
|
| 62 |
assert "INFO" in output
|
| 63 |
assert "neurobridge.format_check" in output
|
| 64 |
assert "payload" in output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
assert "INFO" in output
|
| 63 |
assert "neurobridge.format_check" in output
|
| 64 |
assert "payload" in output
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_get_logger_respects_subsequent_level_change() -> None:
|
| 68 |
+
"""The most recent call wins on level — fixes a silent no-op regression."""
|
| 69 |
+
name = "neurobridge.level_change"
|
| 70 |
+
first = get_logger(name, level=logging.INFO)
|
| 71 |
+
assert first.level == logging.INFO
|
| 72 |
+
second = get_logger(name, level=logging.DEBUG)
|
| 73 |
+
assert second is first # still the same singleton
|
| 74 |
+
assert second.level == logging.DEBUG
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_get_logger_does_not_clobber_pre_attached_handlers() -> None:
|
| 78 |
+
"""If a framework pre-attached a handler, we still apply our config."""
|
| 79 |
+
name = "neurobridge.pre_attached"
|
| 80 |
+
pre_existing = logging.NullHandler()
|
| 81 |
+
logging.getLogger(name).addHandler(pre_existing)
|
| 82 |
+
|
| 83 |
+
logger = get_logger(name)
|
| 84 |
+
|
| 85 |
+
# Our stdout StreamHandler was added alongside the pre-existing handler.
|
| 86 |
+
assert pre_existing in logger.handlers
|
| 87 |
+
assert any(
|
| 88 |
+
isinstance(h, logging.StreamHandler) and getattr(h, "stream", None) is not None
|
| 89 |
+
and h is not pre_existing
|
| 90 |
+
for h in logger.handlers
|
| 91 |
+
)
|
| 92 |
+
# And our config (level + propagate) actually took effect.
|
| 93 |
+
assert logger.level == logging.INFO
|
| 94 |
+
assert logger.propagate is False
|