diff --git a/conftest.py b/conftest.py index 1c7a4787..5422cbc2 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,9 @@ +import logging +from contextlib import contextmanager +from typing import Generator + import pytest +from _pytest.logging import LogCaptureHandler def to_int(value): @@ -39,3 +44,61 @@ def pytest_collection_modifyitems(config, items): @pytest.fixture def mcmc_seed(request): return request.config.getoption("--mcmc-seed") + + +@contextmanager +def local_caplog_fn( + level: int = logging.INFO, name: str = "liesel" +) -> Generator[LogCaptureHandler, None, None]: + """ + Context manager that captures records from non-propagating loggers. + + After the end of the ``with`` statement, the log level is restored to its original + value. Code adapted from `this GitHub comment `_. + + .. _GH: https://github.com/pytest-dev/pytest/issues/3697#issuecomment-790925527 + + Parameters + ---------- + level + The log level. + name + The name of the logger to update. + """ + + logger = logging.getLogger(name) + + old_level = logger.level + logger.setLevel(level) + + handler = LogCaptureHandler() + logger.addHandler(handler) + + try: + yield handler + finally: + logger.setLevel(old_level) + logger.removeHandler(handler) + + +@pytest.fixture +def local_caplog(): + """ + Fixture that yields a context manager for capturing records from non-propagating + loggers. + + Examples + -------- + Usage example:: + + import liesel.liesel.distreg as dr + + def test_build_empty(local_caplog): + with local_caplog() as caplog: + drb = dr.DistRegBuilder() + model = drb.build() + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "WARNING" + """ + + yield local_caplog_fn diff --git a/liesel/__init__.py b/liesel/__init__.py index 2f85842f..f894ed86 100644 --- a/liesel/__init__.py +++ b/liesel/__init__.py @@ -39,10 +39,10 @@ from .__version__ import __version__, __version_info__ # isort: skip # noqa: F401 from . import goose, liesel, tfp -from .logging import setup_logger +from .logging import reset_logger, setup_logger # because logger setup takes place after importing the submodules, it only affects # log messages emitted at runtime setup_logger() -__all__ = ["goose", "liesel", "tfp"] +__all__ = ["goose", "liesel", "tfp", "reset_logger"] diff --git a/liesel/logging.py b/liesel/logging.py index 90ae757d..7131c9c9 100644 --- a/liesel/logging.py +++ b/liesel/logging.py @@ -4,74 +4,120 @@ def setup_logger() -> None: """ - Sets up a basic `StreamHandler`, which prints log messages to the terminal. - The default log level of the `StreamHandler` is set to "info". - """ + Sets up a basic ``StreamHandler`` that prints log messages to the terminal. + The default log level of the ``StreamHandler`` is set to "info". - logger = logging.getLogger("root") - logger.setLevel(logging.DEBUG) + The global log level for Liesel can be adjusted like this:: - ch = logging.StreamHandler() - ch.setLevel(logging.INFO) + import logging + logger = logging.getLogger("liesel") + logger.level = logging.WARNING - formatter = logging.Formatter("%(levelname)s - %(message)s") + This will set the log level to "warning". + """ - ch.setFormatter(formatter) - logger.addHandler(ch) + # We adjust only our library's logger + logger = logging.getLogger("liesel") + # This is the level that will in principle be handled by the logger. + # If it is set, for example, to logging.WARNING, this logger will never + # emit messages of a level below warning + logger.setLevel(logging.DEBUG) -def add_file_handler( - path: str | Path, - level: str, - logger: str = "liesel", - fmt: str = "%(asctime)s - %(levelname)s - %(name)s - %(message)s", -) -> None: - """ - Adds a file handler for logging output. + # By setting this to False, we prevent the Liesel log messages from being passed on + # to the root logger. This prevents duplication of the log messages + logger.propagate = False - ## Arguments + # This is the default handler that we set for our log messages + handler = logging.StreamHandler() + handler.setLevel(logging.INFO) - - `path`: Absolute path to log file. If it does not exist, it will be created. - If any parent directory does not exist, it will be created as well. - - `level`: The level of messages to log to the specified file. Can be "debug", - "info", "warning", "error" or "critical". The logger will catch all messages - from the specified level upwards. - - `logger`: The name of the logger to configure the file handler for. Can be, - for instance, the full name of a Liesel module. For example, to configure a - logger for `liesel.goose`, the logger should be specified as "liesel.goose". - - `fmt`: Formatting string, see the documentation of the standard library's - `logging.Formatter` class. + # We define the format of log messages for this handler + formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s") + handler.setFormatter(formatter) - ## Examples + logger.addHandler(handler) - A basic file handler, catching all log messages in the Liesel framework: - ```python - import logging - import liesel as lsl +def reset_logger() -> None: + """ + Resets the Liesel logger. - lsl.logging.add_file_handler(path="path/to/logfile.log", level="debug") + Specifically, this function... + - ... resets the level of the Liesel logger to ``logging.NOTSET``. + - ... sets ``propagate=True`` for the Liesel logger. + - ... removes *all* handlers from the Liesel logger. + + This function is useful if you want to set up a custom logging configuration. + """ + + # We adjust only our library's logger logger = logging.getLogger("liesel") - logger.warning("My warning message") - ``` - A file handler that catches only log messages from the `liesel.goose` module of - level "warning" or higher: + # Removes the level of the logger. All log messages will be propagated + logger.setLevel(logging.NOTSET) - ```python - import logging - import liesel as lsl + # By setting this to True, we allow the Liesel log messages to be passed on + # to the root logger + logger.propagate = True - lsl.logging.add_file_handler( - path="path/to/goose_warnings.log", - level="warning", - logger="liesel.goose" - ) + # Removes all handlers from the Liesel logger + for handler in logger.handlers: + logger.removeHandler(handler) - logger = logging.getLogger("liesel") - logger.warning("My warning message") - ``` + +def add_file_handler( + path: str | Path, + level: str, + logger: str = "liesel", + fmt: str = "%(asctime)s - %(levelname)s - %(name)s - %(message)s", +) -> None: + """ + Adds a file handler to a logger. + + Parameters + ---------- + path + Absolute path to the log file. If it does not exist, it will be created. + If any parent directory does not exist, it will be created as well. + level + The log level of the messages to write to the file. Can be ``"debug"``, + ``"info"``, ``"warning"``, ``"error"`` or ``"critical"``. The file will + contain all messages from the specified level upwards. + logger + The name of the logger to configure the file handler for. Can be, for example, + the full name of a Liesel module. For :mod:`liesel.goose`, the argument should + be specified as ``"liesel.goose"``, etc. + fmt + Formatting string. See the documentation of the :class:`logging.Formatter`. + + Examples + -------- + A basic file handler catching all log messages from Liesel:: + + import logging + import liesel as lsl + + lsl.logging.add_file_handler(path="path/to/logfile.log", level="debug") + + logger = logging.getLogger("liesel") + logger.warning("My warning message") + + A file handler that catches only log messages from the :mod:`liesel.goose` module + of level "warning" or higher:: + + import logging + import liesel as lsl + + lsl.logging.add_file_handler( + path="path/to/goose_warnings.log", + level="warning", + logger="liesel.goose" + ) + + logger = logging.getLogger("liesel") + logger.warning("My warning message") """ path = Path(path) @@ -81,12 +127,12 @@ def add_file_handler( path.parent.mkdir(parents=True, exist_ok=True) - log = logging.getLogger(logger) - fh = logging.FileHandler(path) + _logger = logging.getLogger(logger) + handler = logging.FileHandler(path) - fh.setLevel(getattr(logging, level.upper())) + handler.setLevel(getattr(logging, level.upper())) formatter = logging.Formatter(fmt, "%Y-%m-%d %H:%M:%S") - fh.setFormatter(formatter) + handler.setFormatter(formatter) - log.addHandler(fh) + _logger.addHandler(handler) diff --git a/tests/liesel/test_distreg.py b/tests/liesel/test_distreg.py index 6ecd5c16..a8a53607 100644 --- a/tests/liesel/test_distreg.py +++ b/tests/liesel/test_distreg.py @@ -256,13 +256,14 @@ def test_add_response_first(self, y): with pytest.raises(RuntimeError): drb.add_response(y, "Normal") - def test_build_empty(self, caplog): + def test_build_empty(self, local_caplog): """An empty model can be built with a warning.""" - drb = dr.DistRegBuilder() - model = drb.build() - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == "WARNING" - assert model + with local_caplog() as caplog: + drb = dr.DistRegBuilder() + model = drb.build() + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "WARNING" + assert model class TestCopRegBuilder: