Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix logging #9

Merged
merged 7 commits into from
Aug 31, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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>`_.

.. _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
4 changes: 2 additions & 2 deletions liesel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
156 changes: 101 additions & 55 deletions liesel/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
13 changes: 7 additions & 6 deletions tests/liesel/test_distreg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down