reconplogger 5.0.0.dev1__tar.gz → 5.0.0.dev2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reconplogger
3
- Version: 5.0.0.dev1
3
+ Version: 5.0.0.dev2
4
4
  Summary: omni:us python logging package
5
5
  Author-email: Mauricio Villegas <mauricio@omnius.com>
6
6
  License-Expression: MIT
@@ -75,7 +75,8 @@ The package contains essentially the following things:
75
75
  - A default logging configuration.
76
76
  - A function for loading logging configuration for regular python code.
77
77
  - A function for loading logging configuration for flask-based microservices.
78
- - Root logger configuration via ``LOGGER_ROOT_HANDLER`` so third-party library logs are also captured.
78
+ - Root logger configuration via ``LOGGER_ROOT_HANDLER`` with independent ``LOGGER_ROOT_LEVEL`` control.
79
+ - Singleton logger setup (``logger_setup``); call ``reset_configs`` before reconfiguring.
79
80
  - Automatic correlation ID management in Flask services via ``flask_app_logger_setup``.
80
81
  - An inheritable class to add a logger property.
81
82
  - A context manager to set and get the correlation id.
@@ -22,7 +22,8 @@ The package contains essentially the following things:
22
22
  - A default logging configuration.
23
23
  - A function for loading logging configuration for regular python code.
24
24
  - A function for loading logging configuration for flask-based microservices.
25
- - Root logger configuration via ``LOGGER_ROOT_HANDLER`` so third-party library logs are also captured.
25
+ - Root logger configuration via ``LOGGER_ROOT_HANDLER`` with independent ``LOGGER_ROOT_LEVEL`` control.
26
+ - Singleton logger setup (``logger_setup``); call ``reset_configs`` before reconfiguring.
26
27
  - Automatic correlation ID management in Flask services via ``flask_app_logger_setup``.
27
28
  - An inheritable class to add a logger property.
28
29
  - A context manager to set and get the correlation id.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reconplogger
3
- Version: 5.0.0.dev1
3
+ Version: 5.0.0.dev2
4
4
  Summary: omni:us python logging package
5
5
  Author-email: Mauricio Villegas <mauricio@omnius.com>
6
6
  License-Expression: MIT
@@ -75,7 +75,8 @@ The package contains essentially the following things:
75
75
  - A default logging configuration.
76
76
  - A function for loading logging configuration for regular python code.
77
77
  - A function for loading logging configuration for flask-based microservices.
78
- - Root logger configuration via ``LOGGER_ROOT_HANDLER`` so third-party library logs are also captured.
78
+ - Root logger configuration via ``LOGGER_ROOT_HANDLER`` with independent ``LOGGER_ROOT_LEVEL`` control.
79
+ - Singleton logger setup (``logger_setup``); call ``reset_configs`` before reconfiguring.
79
80
  - Automatic correlation ID management in Flask services via ``flask_app_logger_setup``.
80
81
  - An inheritable class to add a logger property.
81
82
  - A context manager to set and get the correlation id.
@@ -11,7 +11,7 @@ from typing import Optional, Union
11
11
  import pythonjsonlogger
12
12
  import yaml
13
13
 
14
- __version__ = "5.0.0.dev1"
14
+ __version__ = "5.0.0.dev2"
15
15
 
16
16
  __all__ = [
17
17
  "RLoggerProperty",
@@ -118,6 +118,12 @@ configs_loaded = set()
118
118
  # Internal state for singleton primary logger
119
119
  _primary_logger: Optional[logging.Logger] = None
120
120
 
121
+ ENV_CFG = "LOGGER_CFG"
122
+ ENV_NAME = "LOGGER_NAME"
123
+ ENV_LEVEL = "LOGGER_LEVEL"
124
+ ENV_ROOT_HANDLER = "LOGGER_ROOT_HANDLER"
125
+ ENV_ROOT_LEVEL = "LOGGER_ROOT_LEVEL"
126
+
121
127
 
122
128
  def reset_configs():
123
129
  """Resets reconplogger's internal configuration state.
@@ -130,7 +136,7 @@ def reset_configs():
130
136
  _primary_logger = None
131
137
 
132
138
 
133
- def load_config(cfg: Optional[Union[str, dict]] = None, reload: bool = False):
139
+ def load_config(cfg: Optional[Union[str, dict]] = None):
134
140
  """Loads a logging configuration from path or environment variable or dictionary object.
135
141
 
136
142
  Args:
@@ -140,14 +146,14 @@ def load_config(cfg: Optional[Union[str, dict]] = None, reload: bool = False):
140
146
  Returns:
141
147
  The logging package object.
142
148
  """
143
- if (
144
- cfg is None
145
- or cfg in {"", "reconplogger_default_cfg"}
146
- or (cfg in os.environ and os.environ[cfg] == "reconplogger_default_cfg")
147
- ):
149
+ if cfg is None:
148
150
  cfg_dict = reconplogger_default_cfg
149
151
  elif isinstance(cfg, dict):
150
152
  cfg_dict = cfg
153
+ elif cfg in {"", "reconplogger_default_cfg"} or (
154
+ cfg in os.environ and os.environ[cfg] == "reconplogger_default_cfg"
155
+ ):
156
+ cfg_dict = reconplogger_default_cfg
151
157
  elif isinstance(cfg, str):
152
158
  try:
153
159
  if os.path.isfile(cfg):
@@ -172,7 +178,7 @@ def load_config(cfg: Optional[Union[str, dict]] = None, reload: bool = False):
172
178
  cfg_dict["disable_existing_loggers"] = False
173
179
 
174
180
  cfg_hash = yaml.safe_dump(cfg_dict).__hash__()
175
- if reload or cfg_hash not in configs_loaded:
181
+ if cfg_hash not in configs_loaded:
176
182
  logging.config.dictConfig(cfg_dict)
177
183
  configs_loaded.add(cfg_hash)
178
184
 
@@ -234,13 +240,6 @@ def add_file_handler(
234
240
  return file_handler
235
241
 
236
242
 
237
- def test_logger(logger: logging.Logger):
238
- """Logs one message to each debug, info and warning levels intended for testing."""
239
- logger.debug("reconplogger test debug message.")
240
- logger.info("reconplogger test info message.")
241
- logger.warning("reconplogger test warning message.")
242
-
243
-
244
243
  def get_logger(logger_name: str) -> logging.Logger:
245
244
  """Returns an already existing logger.
246
245
 
@@ -254,7 +253,7 @@ def get_logger(logger_name: str) -> logging.Logger:
254
253
  ValueError: If the logger does not exist.
255
254
  """
256
255
  if logger_name not in logging.Logger.manager.loggerDict and logger_name not in logging.root.manager.loggerDict:
257
- raise ValueError('Logger "' + str(logger_name) + '" not defined.')
256
+ raise ValueError(f'Logger "{logger_name}" not defined.')
258
257
  return logging.getLogger(logger_name)
259
258
 
260
259
 
@@ -262,103 +261,82 @@ def logger_setup(
262
261
  logger_name: str = "plain_logger",
263
262
  config: Optional[str] = None,
264
263
  level: Optional[str] = None,
265
- env_prefix: str = "LOGGER",
266
- reload: bool = False,
267
- init_messages: bool = False,
268
264
  ) -> logging.Logger:
269
265
  """Sets up logging configuration and returns the logger.
270
266
 
271
- If the environment variable ``{env_prefix}_HANDLER`` is set to the name of a handler
267
+ If the environment variable ``LOGGER_ROOT_HANDLER`` is set to the name of a handler
272
268
  defined in the logging config, that handler is installed on the root logger so that
273
269
  all third-party loggers (which propagate to the root by default) are also captured.
270
+ The primary logger level remains controlled by ``level`` / ``LOGGER_LEVEL``, while the
271
+ root logger level can be controlled independently through ``LOGGER_ROOT_LEVEL``.
274
272
  On subsequent calls the same primary logger is returned without reconfiguring the root.
273
+ To force a fresh configuration pass, call :func:`reset_configs` first.
275
274
 
276
275
  Args:
277
276
  logger_name: Name of the logger that needs to be used.
278
277
  config: Configuration string or path to configuration file or configuration file via environment variable.
279
278
  level: Optional logging level that overrides one in config.
280
- env_prefix: Environment variable names prefix for overriding logger configuration.
281
- reload: Whether to reload logging configuration overriding any previous settings.
282
- init_messages: Whether to log init and test messages.
283
279
 
284
280
  Returns:
285
281
  The logger object.
286
282
  """
287
283
  global _primary_logger
288
284
 
289
- if not isinstance(env_prefix, str) or not env_prefix:
290
- raise ValueError("env_prefix is required to be a non-empty string.")
291
- env_cfg = env_prefix + "_CFG"
292
- env_name = env_prefix + "_NAME"
293
- env_level = env_prefix + "_LEVEL"
294
- env_root_handler = env_prefix + "_ROOT_HANDLER"
295
-
296
285
  # Return primary logger on subsequent calls (singleton behaviour)
297
- if _primary_logger is not None and not reload:
298
- name = os.getenv(env_name, logger_name)
299
- if name != _primary_logger.name or config or level or init_messages:
286
+ if _primary_logger is not None:
287
+ name = os.getenv(ENV_NAME, logger_name)
288
+ if name != _primary_logger.name or config or level:
300
289
  _primary_logger.debug(
301
290
  "logger_setup called again with different arguments; returning existing primary logger."
302
291
  )
303
292
  return _primary_logger
304
293
 
305
294
  # Configure logging
306
- load_config(os.getenv(env_cfg, config), reload=reload)
295
+ load_config(os.getenv(ENV_CFG, config))
307
296
 
308
297
  # Get logger
309
- name = os.getenv(env_name, logger_name)
298
+ name = os.getenv(ENV_NAME, logger_name)
310
299
  logger = get_logger(name)
311
300
 
312
- # Resolve the effective log level
301
+ # Resolve the effective log level for the primary logger
313
302
  effective_level: Optional[int] = None
314
- if env_level in os.environ:
315
- level = os.getenv(env_level)
303
+ if ENV_LEVEL in os.environ:
304
+ level = os.getenv(ENV_LEVEL)
316
305
  if level:
317
- if isinstance(level, str):
318
- if level not in logging_levels:
319
- raise ValueError('Invalid logging level: "' + str(level) + '".')
320
- effective_level = logging_levels[level]
321
- else:
322
- raise ValueError("Expected level argument to be a string.")
323
-
324
- # Configure root logger when LOGGER_ROOT_HANDLER env var is set
325
- root_handler_name = os.getenv(env_root_handler)
326
- if root_handler_name:
327
- # _configure_root_logger installs the root handler and clears all named
328
- # loggers' handlers so records flow through the single root handler.
329
- _configure_root_logger(root_handler_name, effective_level)
330
- else:
331
- # Apply log level overrides to the named logger's handlers
332
- if effective_level is not None:
333
- for handler in logger.handlers:
334
- if not isinstance(handler, logging.FileHandler):
335
- handler.setLevel(effective_level)
306
+ if level not in logging_levels:
307
+ raise ValueError('Invalid logging level: "' + str(level) + '".')
308
+ effective_level = logging_levels[level]
309
+
310
+ _configure_root_logger()
311
+
312
+ # Apply log level overrides to the named logger's handlers
313
+ if effective_level is not None:
314
+ for handler in logger.handlers:
315
+ if not isinstance(handler, logging.FileHandler):
316
+ handler.setLevel(effective_level)
336
317
 
337
318
  # Add correlation id filter
338
319
  logger.addFilter(_CorrelationIdLoggingFilter())
339
320
 
340
- # Log configured done and test logger
341
- if init_messages:
342
- logger.info("reconplogger (v" + __version__ + ") logger configured.")
343
- test_logger(logger)
344
-
345
321
  logger._reconplogger_setup = True
346
322
  _primary_logger = logger
347
323
  return logger
348
324
 
349
325
 
350
- def _configure_root_logger(handler_name: str, level: Optional[int]) -> None:
351
- """Installs a named handler on the root logger and clears all named loggers.
326
+ def _configure_root_logger() -> None:
327
+ """Installs a named handler on the root logger and removes stream handlers from named loggers.
352
328
 
353
329
  After this call every log record in the process flows through the single
354
330
  root handler, regardless of which named logger emitted it. All named
355
- loggers (except ``null_logger``) have their handlers removed and
356
- ``propagate`` set to ``True`` so records bubble up to the root.
357
-
358
- Args:
359
- handler_name: Name of the handler as defined in the logging config (e.g. ``json_handler``).
360
- level: Optional numeric log level; falls back to the handler's own level.
331
+ loggers (except those with only ``NullHandler`` instances) have their
332
+ ``StreamHandler`` instances removed and ``propagate`` set to ``True`` so
333
+ records bubble up to the root while keeping non-stream handlers such as
334
+ file handlers attached.
361
335
  """
336
+ handler_name = os.getenv(ENV_ROOT_HANDLER)
337
+ if not handler_name:
338
+ return
339
+
362
340
  # Retrieve the already-configured handler by name
363
341
  assert logging.root.manager.loggerDict # just to verify config is loaded
364
342
  handler_obj = logging._handlers.get(handler_name) # type: ignore[attr-defined]
@@ -370,15 +348,28 @@ def _configure_root_logger(handler_name: str, level: Optional[int]) -> None:
370
348
 
371
349
  root = logging.getLogger()
372
350
  root.handlers = [handler_obj]
373
- effective_level = level if level is not None else handler_obj.level
374
- root.setLevel(effective_level)
351
+ root_level_name = os.getenv(ENV_ROOT_LEVEL)
352
+ level = handler_obj.level
353
+ if root_level_name:
354
+ if root_level_name not in logging_levels:
355
+ raise ValueError('Invalid logging level: "' + str(root_level_name) + '".')
356
+ level = logging_levels[root_level_name]
357
+ handler_obj.setLevel(level)
358
+ root.setLevel(level)
375
359
 
376
360
  logging.captureWarnings(True)
377
361
 
378
- # Clear handlers from all named loggers so nothing duplicates the root output.
362
+ # Remove stream handlers from named loggers so their remaining handlers, such
363
+ # as FileHandler, keep working without duplicating root stream output.
379
364
  for lg_obj in logging.Logger.manager.loggerDict.values():
380
- if isinstance(lg_obj, logging.Logger) and lg_obj.name != "null_logger":
381
- lg_obj.handlers = []
365
+ if isinstance(lg_obj, logging.Logger):
366
+ if any(isinstance(h, logging.NullHandler) for h in lg_obj.handlers):
367
+ continue
368
+ lg_obj.handlers = [
369
+ handler
370
+ for handler in lg_obj.handlers
371
+ if not isinstance(handler, logging.StreamHandler) or isinstance(handler, logging.FileHandler)
372
+ ]
382
373
  lg_obj.propagate = True
383
374
 
384
375
 
@@ -390,7 +381,6 @@ def flask_app_logger_setup(
390
381
  logger_name: str = "plain_logger",
391
382
  config: Optional[str] = None,
392
383
  level: Optional[str] = None,
393
- env_prefix: str = "LOGGER",
394
384
  ) -> logging.Logger:
395
385
  """Sets up logging configuration, configures flask to use it, and returns the logger.
396
386
 
@@ -399,7 +389,6 @@ def flask_app_logger_setup(
399
389
  logger_name: Name of the logger that needs to be used.
400
390
  config: Configuration string or path to configuration file or configuration file via environment variable.
401
391
  level: Optional logging level that overrides one in config.
402
- env_prefix: Environment variable names prefix for overriding logger configuration.
403
392
 
404
393
  Returns:
405
394
  The logger object.
@@ -409,7 +398,6 @@ def flask_app_logger_setup(
409
398
  logger_name=logger_name,
410
399
  config=config,
411
400
  level=level,
412
- env_prefix=env_prefix,
413
401
  )
414
402
 
415
403
  # Apply WSGI middleware to manage correlation ID at the transport layer
@@ -63,14 +63,14 @@ class TestReconplogger(unittest.TestCase):
63
63
 
64
64
  def test_log_level(self):
65
65
  """Test load config with the default config and plain logger changing the log level."""
66
- logger = reconplogger.logger_setup(level="INFO", reload=True)
66
+ logger = reconplogger.logger_setup(level="INFO")
67
67
  self.assertEqual(logger.handlers[0].level, logging.INFO)
68
68
  reconplogger.reset_configs()
69
- logger = reconplogger.logger_setup(level="ERROR", reload=True)
69
+ logger = reconplogger.logger_setup(level="ERROR")
70
70
  self.assertEqual(logger.handlers[0].level, logging.ERROR)
71
71
  reconplogger.reset_configs()
72
72
  with patch.dict(os.environ, {"LOGGER_LEVEL": "WARNING"}):
73
- logger = reconplogger.logger_setup(level="INFO", env_prefix="LOGGER", reload=True)
73
+ logger = reconplogger.logger_setup(level="INFO")
74
74
  self.assertEqual(logger.handlers[0].level, logging.WARNING)
75
75
 
76
76
  def test_default_logger_with_exception(self):
@@ -125,18 +125,20 @@ class TestReconplogger(unittest.TestCase):
125
125
  self.assertRaises(ValueError, lambda: reconplogger.replace_logger_handlers(logger, False))
126
126
  self.assertRaises(ValueError, lambda: reconplogger.replace_logger_handlers(False, False))
127
127
 
128
- def test_init_messages(self):
128
+ def test_logger_writes_messages(self):
129
129
  logger = reconplogger.logger_setup()
130
130
  with capture_logs(logger) as captured:
131
- reconplogger.test_logger(logger)
131
+ logger.debug("reconplogger test debug message.")
132
+ logger.info("reconplogger test info message.")
133
+ logger.warning("reconplogger test warning message.")
132
134
  self.assertIn("WARNING", captured.getvalue())
133
135
  self.assertIn("reconplogger test warning message", captured.getvalue())
134
136
 
135
137
  @patch.dict(
136
138
  os.environ,
137
139
  {
138
- "RECONPLOGGER_NAME": "example_logger",
139
- "RECONPLOGGER_CFG": """{
140
+ "LOGGER_NAME": "example_logger",
141
+ "LOGGER_CFG": """{
140
142
  "version": 1,
141
143
  "formatters": {
142
144
  "verbose": {
@@ -159,28 +161,25 @@ class TestReconplogger(unittest.TestCase):
159
161
  }""",
160
162
  },
161
163
  )
162
- def test_logger_setup_env_prefix(self):
163
- logger = reconplogger.logger_setup(env_prefix="RECONPLOGGER")
164
+ def test_logger_setup_env_vars(self):
165
+ logger = reconplogger.logger_setup()
164
166
  info_msg = "info message env logger"
165
167
  with LogCapture(names="example_logger") as log:
166
168
  logger.info(info_msg)
167
169
  log.check(("example_logger", "INFO", info_msg))
168
170
 
169
- def test_logger_setup_env_prefix_invalid(self):
170
- for env_prefix in [None, ""]:
171
- with self.subTest(env_prefix):
172
- with self.assertRaises(ValueError):
173
- reconplogger.logger_setup(env_prefix=env_prefix)
174
-
175
171
  def test_undefined_logger(self):
176
172
  """Test setting up a logger not already defined."""
177
173
  self.assertRaises(ValueError, lambda: reconplogger.logger_setup("undefined_logger"))
178
174
 
179
175
  def test_logger_setup_invalid_level(self):
180
176
  with self.assertRaises(ValueError):
181
- reconplogger.logger_setup(level="INVALID", reload=True)
177
+ reconplogger.logger_setup(level="INVALID")
182
178
  with self.assertRaises(ValueError):
183
- reconplogger.logger_setup(level=True, reload=True)
179
+ reconplogger.logger_setup(level=True)
180
+ with patch.dict(os.environ, {"LOGGER_ROOT_HANDLER": "json_handler", "LOGGER_ROOT_LEVEL": "INVALID"}):
181
+ with self.assertRaises(ValueError):
182
+ reconplogger.logger_setup()
184
183
 
185
184
  @patch.dict(os.environ, {"LOGGER_NAME": "json_logger"})
186
185
  def test_correlation_id_context(self):
@@ -203,13 +202,13 @@ class TestReconplogger(unittest.TestCase):
203
202
  @patch.dict(
204
203
  os.environ,
205
204
  {
206
- "RECONPLOGGER_CFG": "reconplogger_default_cfg",
207
- "RECONPLOGGER_NAME": "json_logger",
205
+ "LOGGER_CFG": "reconplogger_default_cfg",
206
+ "LOGGER_NAME": "json_logger",
208
207
  },
209
208
  )
210
209
  def test_flask_app_logger_setup(self):
211
210
  app = Flask(__name__)
212
- reconplogger.flask_app_logger_setup(env_prefix="RECONPLOGGER", flask_app=app)
211
+ reconplogger.flask_app_logger_setup(flask_app=app)
213
212
  assert app.logger.filters # pylint: disable=no-member
214
213
  assert app.before_request_funcs
215
214
  assert app.after_request_funcs
@@ -227,8 +226,8 @@ class TestReconplogger(unittest.TestCase):
227
226
  @patch.dict(
228
227
  os.environ,
229
228
  {
230
- "RECONPLOGGER_CFG": "reconplogger_default_cfg",
231
- "RECONPLOGGER_NAME": "json_logger",
229
+ "LOGGER_CFG": "reconplogger_default_cfg",
230
+ "LOGGER_NAME": "json_logger",
232
231
  },
233
232
  )
234
233
  def test_flask_app_correlation_id(self):
@@ -251,7 +250,7 @@ class TestReconplogger(unittest.TestCase):
251
250
  logs.check((app.logger.name, "ERROR"))
252
251
  self.assertEqual(response.status_code, 500)
253
252
 
254
- reconplogger.flask_app_logger_setup(env_prefix="RECONPLOGGER", flask_app=app)
253
+ reconplogger.flask_app_logger_setup(flask_app=app)
255
254
  client = app.test_client()
256
255
 
257
256
  self.assertRaises(RuntimeError, lambda: reconplogger.get_correlation_id())
@@ -372,7 +371,8 @@ class TestReconplogger(unittest.TestCase):
372
371
  self.assertTrue(any([debug_msg in line for line in open(log_file).readlines()]))
373
372
 
374
373
  log_file = os.path.join(tmpdir, "file2.log")
375
- logger = reconplogger.logger_setup(logger_name="plain_logger", level="DEBUG", reload=True)
374
+ reconplogger.reset_configs()
375
+ logger = reconplogger.logger_setup(logger_name="plain_logger", level="DEBUG")
376
376
  reconplogger.add_file_handler(logger, file_path=log_file, level="ERROR")
377
377
  self.assertEqual(logger.handlers[0].level, logging.DEBUG)
378
378
  self.assertEqual(logger.handlers[1].level, logging.ERROR)
@@ -435,17 +435,89 @@ class TestReconplogger(unittest.TestCase):
435
435
  )
436
436
  def test_root_logger_handler(self):
437
437
  """When LOGGER_ROOT_HANDLER is set, the root logger receives the handler and
438
- named loggers propagate to it without handlers of their own."""
438
+ LOGGER_LEVEL no longer changes the root logger level."""
439
439
  logger = reconplogger.logger_setup()
440
440
  root = logging.getLogger()
441
441
  # Root logger should have the json_handler installed
442
442
  handler_names = [h.__class__.__name__ for h in root.handlers]
443
443
  self.assertIn("StreamHandler", handler_names)
444
- self.assertEqual(root.level, logging.INFO)
445
- # Named logger should have no own handlers; records bubble up to root
444
+ self.assertEqual(root.level, logging.WARNING)
445
+ self.assertEqual(root.handlers[0].level, logging.WARNING)
446
+ # Named logger should have no own stream handlers; records bubble up to root
446
447
  self.assertEqual(logger.handlers, [])
447
448
  self.assertTrue(logger.propagate)
448
449
 
450
+ @patch.dict(
451
+ os.environ,
452
+ {
453
+ "LOGGER_ROOT_HANDLER": "json_handler",
454
+ "LOGGER_ROOT_LEVEL": "DEBUG",
455
+ },
456
+ )
457
+ def test_root_logger_level_emits_debug_logs(self):
458
+ """LOGGER_ROOT_LEVEL controls the root logger and root handler thresholds."""
459
+ logger = reconplogger.logger_setup()
460
+ root = logging.getLogger()
461
+ self.assertEqual(root.level, logging.DEBUG)
462
+ self.assertEqual(root.handlers[0].level, logging.DEBUG)
463
+
464
+ captured = StringIO()
465
+ with patch.object(root.handlers[0], "stream", captured):
466
+ logger.debug("debug message via root handler")
467
+
468
+ self.assertIn("debug message via root handler", captured.getvalue())
469
+
470
+ def test_root_logger_keeps_file_handlers(self):
471
+ """Root logger setup removes stream handlers from named loggers but keeps file handlers."""
472
+ tmpdir = tempfile.mkdtemp(prefix="_reconplogger_root_test_")
473
+ log_file = os.path.join(tmpdir, "root.log")
474
+ config = {
475
+ "version": 1,
476
+ "formatters": {
477
+ "plain": {
478
+ "format": "%(levelname)s %(message)s",
479
+ }
480
+ },
481
+ "handlers": {
482
+ "plain_handler": {
483
+ "class": "logging.StreamHandler",
484
+ "formatter": "plain",
485
+ "level": "WARNING",
486
+ },
487
+ "file_handler": {
488
+ "class": "logging.FileHandler",
489
+ "formatter": "plain",
490
+ "filename": log_file,
491
+ "level": "ERROR",
492
+ },
493
+ },
494
+ "loggers": {
495
+ "plain_logger": {
496
+ "level": "DEBUG",
497
+ "handlers": ["plain_handler", "file_handler"],
498
+ }
499
+ },
500
+ }
501
+
502
+ try:
503
+ with patch.dict(os.environ, {"LOGGER_ROOT_HANDLER": "plain_handler"}, clear=False):
504
+ logger = reconplogger.logger_setup(config=config)
505
+
506
+ self.assertEqual(len(logger.handlers), 1)
507
+ self.assertIsInstance(logger.handlers[0], logging.FileHandler)
508
+ self.assertTrue(logger.propagate)
509
+
510
+ captured = StringIO()
511
+ root = logging.getLogger()
512
+ with patch.object(root.handlers[0], "stream", captured):
513
+ logger.error("root logger keeps file handlers")
514
+
515
+ logger.handlers[0].close()
516
+ self.assertIn("root logger keeps file handlers", captured.getvalue())
517
+ self.assertTrue(any("root logger keeps file handlers" in line for line in open(log_file).readlines()))
518
+ finally:
519
+ shutil.rmtree(tmpdir)
520
+
449
521
  @patch.dict(
450
522
  os.environ,
451
523
  {"LOGGER_ROOT_HANDLER": "nonexistent_handler"},