mekosotto commited on
Commit
ed5752e
·
1 Parent(s): 47020f2

fix(core): apply level + propagate every call; tolerate pre-attached handlers

Browse files
Files changed (2) hide show
  1. src/core/logger.py +15 -8
  2. 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 return the same Logger
20
- instance and never stack duplicate handlers.
 
 
 
 
 
 
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