diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6b2d9..7ce625d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to - build: - Support setuptools.packages.find.where directive in setup converter. - Support setuptools.data-files directives in setup converter. +- log: Support log levels higher than INFO in `setup_logger()` (#89). ### Changed diff --git a/src/log/README.md b/src/log/README.md index fb99f81..c255a3b 100644 --- a/src/log/README.md +++ b/src/log/README.md @@ -1,3 +1,53 @@ # RFL: log package Logging utility. + +## setup_logger() + +Configure the root logger with a stream handler, formatter, and per-component +filtering. + +### Basic usage + +```python +import logging +from rfl.log import setup_logger + +setup_logger() # INFO level (default) +setup_logger(debug=True) # DEBUG level +setup_logger(level=logging.WARNING) # WARNING and above +setup_logger(level="ERROR") # string level names also accepted +``` + +### Log level + +Level is resolved in this order: + +1. `level` argument (int constant or string name: `DEBUG`, `INFO`, `WARNING`, + `ERROR`, `CRITICAL`) +2. `debug=True` → `DEBUG` (when `level` is omitted) +3. default → `INFO` + +The `debug` flag also controls formatter verbosity (detailed prefixes), independent +of the resolved log level. + +### Component filtering + +By default, only log records from components listed in `log_flags` are shown for +INFO and above. DEBUG records require `debug_flags`. + +```python +setup_logger(log_flags=["rfl"]) # INFO+ from rfl only +setup_logger(debug=True, debug_flags=["network"]) # DEBUG from network only +setup_logger(log_flags=["ALL"], debug_flags=["ALL"]) # show everything +``` + +Component names are derived from the first segment of the logger name +(e.g. `rfl.pkg.module` → `rfl`). + +### Other options + +- `component`: prefix log lines with a fixed component label in the formatter +- `formatter`: formatter class factory (default: TTY or daemon auto-selection) +- `clear`: when `True` (default), remove existing root handlers before setup; + set `clear=False` to stack handlers diff --git a/src/log/rfl/log/__init__.py b/src/log/rfl/log/__init__.py index 9e04c96..887c15a 100644 --- a/src/log/rfl/log/__init__.py +++ b/src/log/rfl/log/__init__.py @@ -4,7 +4,7 @@ # # SPDX-License-Identifier: LGPL-3.0-or-later -from typing import Optional, List +from typing import Optional, List, Union import logging from .formatters import TTYFormatter, DaemonFormatter, auto_formatter @@ -22,18 +22,32 @@ def setup_logger( debug: bool = False, + level: Optional[Union[int, str]] = None, log_flags: Optional[List[str]] = None, debug_flags: Optional[List[str]] = None, formatter: logging.Formatter = auto_formatter(), component: Optional[str] = None, clear: bool = True, ) -> None: - """Setup root logger debug level, debug flags and formatter. + """Setup root logger level, debug flags and formatter. + + Log level is resolved in this order: explicit level argument, then + debug=True (DEBUG), otherwise INFO. The debug flag also controls + formatter verbosity, independent of the resolved log level. When clear is True (default), existing root logger handlers are removed before adding a new one, similarly to logging.basicConfig(force=True). """ - if debug: + if level is not None: + if isinstance(level, str): + logging_level = getattr(logging, level.upper(), None) + if not isinstance(logging_level, int): + raise ValueError(f"Unknown log level: {level}") + elif isinstance(level, int): + logging_level = level + else: + raise TypeError(f"Unsupported log level type: {type(level).__name__}") + elif debug: logging_level = logging.DEBUG else: logging_level = logging.INFO diff --git a/src/log/rfl/tests/test_setup.py b/src/log/rfl/tests/test_setup.py index 42496e6..aa59faa 100644 --- a/src/log/rfl/tests/test_setup.py +++ b/src/log/rfl/tests/test_setup.py @@ -48,6 +48,42 @@ def test_setup_debug(self): handler = logger.handlers[-1] self.assertEqual(handler.level, logging.DEBUG) + def test_setup_warning(self): + setup_logger(level=logging.WARNING) + logger = logging.getLogger() + self.assertEqual(logger.level, logging.WARNING) + handler = logger.handlers[-1] + self.assertEqual(handler.level, logging.WARNING) + + def test_setup_error(self): + setup_logger(level=logging.ERROR) + logger = logging.getLogger() + self.assertEqual(logger.level, logging.ERROR) + handler = logger.handlers[-1] + self.assertEqual(handler.level, logging.ERROR) + + def test_setup_level_string(self): + setup_logger(level="WARNING") + logger = logging.getLogger() + self.assertEqual(logger.level, logging.WARNING) + handler = logger.handlers[-1] + self.assertEqual(handler.level, logging.WARNING) + + def test_setup_level_overrides_debug(self): + setup_logger(level=logging.WARNING, debug=True) + logger = logging.getLogger() + self.assertEqual(logger.level, logging.WARNING) + handler = logger.handlers[-1] + self.assertEqual(handler.level, logging.WARNING) + + def test_setup_invalid_level(self): + with self.assertRaisesRegex(ValueError, r"Unknown log level: NOTALEVEL"): + setup_logger(level="NOTALEVEL") + + def test_setup_invalid_level_type(self): + with self.assertRaisesRegex(TypeError, r"Unsupported log level type: float"): + setup_logger(level=1.5) + def test_setup_filter(self): setup_logger(log_flags=["rfl"]) # get root logger