diff --git a/docs/features/logging.md b/docs/features/logging.md index bdb2f66..31c734a 100644 --- a/docs/features/logging.md +++ b/docs/features/logging.md @@ -21,6 +21,26 @@ setup_logger(Path("build.log")) # console + file build() ``` +### Code location in the console + +By default a console log line carries only the timestamp, the level and the message: + +```text +2026-03-24 11:18:16.429 | INFO | building ... +``` + +Pass `show_code_location=True` to prefix every line with the module, the function and the line number that logged it. This helps while debugging and is noise in a build log: + +```python +setup_logger(show_code_location=True) +``` + +```text +2026-03-24 11:18:16.429 | INFO | my_app.builder:build:12 - building ... +``` + +The log file is not affected by this option. A log file is read for debugging, so it always keeps the code location. + ## Requirements ```{item} REQ-LOGGING_FILE-0.0.1 Print to file @@ -29,6 +49,12 @@ build() The user **shall** be able to specify the log file path. ``` +```{item} REQ-LOGGING_CODE_LOCATION-0.0.1 Configurable code location + + Print the code location (module, function and line number) in the console log only on request. + The user **shall** be able to enable or disable it. It **shall** be disabled by default. +``` + ```{item} REQ-LOGGING-2.0.0 Easy Setup and Use Be easy to set up and use across all modules. diff --git a/src/py_app_dev/core/logging.py b/src/py_app_dev/core/logging.py index 4198c8f..c5b84e8 100644 --- a/src/py_app_dev/core/logging.py +++ b/src/py_app_dev/core/logging.py @@ -19,6 +19,10 @@ logger.level("START", no=38, color="") logger.level("STOP", no=39, color="") +TIME_AND_LEVEL_FORMAT = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " +CODE_LOCATION_FORMAT = "{name}:{function}:{line} - " +MESSAGE_FORMAT = "{message}" + _R = TypeVar("_R") _FuncType = Callable[..., _R] @@ -43,13 +47,25 @@ def time_it(*args: Any, **kwargs: Any) -> _R: return _time_it +@fulfills("REQ-LOGGING_CODE_LOCATION-0.0.1") +def create_log_format(show_code_location: bool = False) -> str: + """Create the console log format, with or without the module, function and line number of the caller.""" + code_location = CODE_LOCATION_FORMAT if show_code_location else "" + return f"{TIME_AND_LEVEL_FORMAT}{code_location}{MESSAGE_FORMAT}" + + @fulfills("REQ-LOGGING_FILE-0.0.1") -def setup_logger(log_file: Path | None = None, clear: bool = True) -> None: - """Setup logger to stdout and optionally to file.""" +def setup_logger(log_file: Path | None = None, clear: bool = True, show_code_location: bool = False) -> None: + """ + Setup logger to stdout and optionally to file. + + The code location is omitted from the console output unless `show_code_location` is set. + It is always kept in the log file, because a log file is read for debugging. + """ logger.remove() logger.add( sys.stdout, - format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {message}", + format=create_log_format(show_code_location), ) if log_file is not None: logger.add(log_file, level="DEBUG") diff --git a/tests/test_logger.py b/tests/test_logger.py index dcb45f8..dc4b148 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,7 +1,45 @@ from pathlib import Path +import pytest + from py_app_dev.core.docs_utils import validates -from py_app_dev.core.logging import log_to_file, logger, setup_logger, time_it +from py_app_dev.core.logging import create_log_format, log_to_file, logger, setup_logger, time_it + + +@pytest.fixture +def console_capture(capsys): + """Capture the console log output and detach the sink afterwards, so that later tests do not write into a closed stream.""" + yield capsys + logger.remove() + + +@pytest.mark.parametrize("show_code_location", [True, False]) +@validates("REQ-LOGGING_CODE_LOCATION-0.0.1") +def test_create_log_format(show_code_location): + log_format = create_log_format(show_code_location) + assert ("{name}" in log_format) is show_code_location + assert ("{function}" in log_format) is show_code_location + assert ("{line}" in log_format) is show_code_location + assert "{message}" in log_format + + +@pytest.mark.parametrize("show_code_location", [True, False]) +@validates("REQ-LOGGING_CODE_LOCATION-0.0.1") +def test_setup_logger_code_location(console_capture, show_code_location): + setup_logger(show_code_location=show_code_location) + logger.info("Detecting C compiler ABI info") + console_output = console_capture.readouterr().out + assert "Detecting C compiler ABI info" in console_output + assert (":test_setup_logger_code_location:" in console_output) is show_code_location + + +@validates("REQ-LOGGING_CODE_LOCATION-0.0.1") +def test_setup_logger_hides_code_location_by_default(console_capture): + setup_logger() + logger.info("Detecting C compiler ABI info") + console_output = console_capture.readouterr().out + assert "Detecting C compiler ABI info" in console_output + assert ":test_setup_logger_hides_code_location_by_default:" not in console_output @validates("REQ-LOGGING_FILE-0.0.1")