Source code for click_extra.logging
# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Logging utilities."""
from __future__ import annotations
import inspect
import logging
import sys
from contextlib import nullcontext
from gettext import gettext as _
from logging import (
NOTSET,
WARNING,
Formatter,
Handler,
Logger,
LogRecord,
_levelToName,
basicConfig,
getLogger,
)
from typing import IO, TYPE_CHECKING, Any, Literal, TypeVar
from unittest.mock import patch
import click
from . import Choice, Context, Parameter
from .colorize import default_theme
from .parameters import ExtraOption
if TYPE_CHECKING:
from collections.abc import Generator, Iterable, Sequence
LOG_LEVELS: dict[str, int] = {
name: value
for value, name in sorted(_levelToName.items(), reverse=True)
if value != NOTSET
}
"""Mapping of :ref:`canonical log level names <levels>` to their integer level.
That's our own version of `logging._nameToLevel
<https://github.com/python/cpython/blob/a379749/Lib/logging/__init__.py#L115-L123>`_,
with a twist:
- sorted from lowest to highest verbosity,
- excludes the following levels:
- ``NOTSET``, which is considered internal
- ``WARN``, which :meth:`is obsolete <logging.Logger.warning>`
- ``FATAL``, which `shouldn't be used <https://github.com/python/cpython/issues/85013>`_
and has been `replaced by CRITICAL
<https://github.com/python/cpython/blob/0df7c3a/Lib/logging/__init__.py#L1538-L1541>`_
"""
DEFAULT_LEVEL: int = WARNING
DEFAULT_LEVEL_NAME: str = _levelToName[DEFAULT_LEVEL]
"""``WARNING`` is the default level we expect any loggers to starts their lives at.
``WARNING`` has been chosen as it is `the level at which the default Python's global
root logger is set up
<https://github.com/python/cpython/blob/0df7c3a/Lib/logging/__init__.py#L1945>`_.
This value is also used as the default level for the ``--verbosity`` option below.
"""
TFormatter = TypeVar("TFormatter", bound=Formatter)
THandler = TypeVar("THandler", bound=Handler)
"""Custom types to be used in type hints below."""
[docs]
class ExtraStreamHandler(Handler):
"""A handler to output logs to console's ``<stderr>``.
Differs to the default `logging.StreamHandler
<https://docs.python.org/3/library/logging.handlers.html#streamhandler>`_ by
using ``click.echo`` to support color printing to ``<stderr>``.
"""
def __init__(self, stream=None):
"""
Initialize the handler.
If stream is not specified, sys.stderr is used.
"""
Handler.__init__(self)
if stream is None:
stream = sys.stderr
self.stream = stream
[docs]
def emit(self, record: LogRecord) -> None:
"""Use ``click.echo`` to print to ``<stderr>``."""
try:
msg = self.format(record)
click.echo(msg, err=True)
# If exception occurs format it to the stream.
except Exception:
self.handleError(record)
[docs]
class ExtraFormatter(Formatter):
[docs]
def formatMessage(self, record: LogRecord) -> str:
"""Colorize the record's log level name before calling the strandard
formatter."""
level = record.levelname.lower()
level_style = getattr(default_theme, level, None)
if level_style:
record.levelname = level_style(level)
return super().formatMessage(record)
[docs]
def extraBasicConfig(
*,
filename: str | None = None,
filemode: str = "a",
format: str | None = "{levelname}: {message}",
datefmt: str | None = None,
style: Literal["%", "{", "$"] = "{",
level: int | str | None = None,
stream: IO[Any] | None = None,
handlers: Iterable[Handler] | None = None,
force: bool = False,
encoding: str | None = None,
errors: str | None = "backslashreplace",
) -> None:
"""Configure the global ``root`` logger.
Same as Python standard library's :func:`logging.basicConfig` but with better
defaults:
============ =========================================== ======================================
Argument :func:`extraBasicConfig` default :func:`logging.basicConfig` default
============ =========================================== ======================================
``handlers`` A single instance of ``ExtraStreamHandler`` False
``style`` ``{`` ``%``
``format`` ``{levelname}: {message}`` ``%(levelname)s:%(name)s:%(message)s``
============ =========================================== ======================================
:param filename: Specifies that a :class:`logging.FileHandler` be created, using the
specified filename, rather than a :class:`logging.StreamHandler`.
:param filemode: If *filename* is specified, open the file in this mode. Defaults
to ``a``.
:param format: Use the specified format string for the handler. Defaults to
``{levelname}: {message}``.
:param datefmt: Use the specified date/time format, as accepted by
:func:`time.strftime`.
:param style: If format is specified, use this style for the format string. One of
``%``, ``{`` or ``$`` for :ref:`printf-style <old-string-formatting>`,
:meth:`str.format` or :class:`string.Template` respectively. Defaults to ``{`` .
:param level: Set the ``root`` logger level to the specified :ref:`level <levels>`.
:param stream: Use the specified stream to initialize the
:class:`logging.StreamHandler`. Note that this argument is incompatible with
*filename* - if both are present, a ``ValueError`` is raised.
:param handlers: If specified, this should be an iterable of already created
handlers to add to the ``root`` logger. Any handlers which don't already have a
formatter set will be assigned the default formatter created in this function.
Note that this argument is incompatible with *filename* or *stream* - if both
are present, a ``ValueError`` is raised.
:param force: If this keyword argument is specified as ``True``, any existing
handlers attached to the ``root`` logger are removed and closed, before carrying
out the configuration as specified by the other arguments.
:param encoding: If this keyword argument is specified along with *filename*, its
value is used when the :class:`logging.FileHandler` is created, and thus used
when opening the output file.
:param errors: If this keyword argument is specified along with *filename*, its
value is used when the :class:`logging.FileHandler` is created, and thus used
when opening the output file. If not specified, the value ``backslashreplace``
is used. Note that if ``None`` is specified, it will be passed as such to
:func:`open`, which means that it will be treated the same as passing
``errors``.
.. important::
Always keep the signature of this function, the default values of its
parameters and its documentation in sync with the one from Python's standard
library.
.. note::
I don't like the camel-cased name of this function and would have called it
``extra_basic_config()``, but it's kept this way for consistency with Python's
standard library.
"""
# Collect all arguments that are not None, because basicConfig is testing the
# presence of them instead of their values. So we'll add them conditionally to
# kwargs.
kwargs = {}
for arg_id in inspect.signature(extraBasicConfig).parameters:
if arg_id in locals() and locals()[arg_id] is not None:
kwargs[arg_id] = locals()[arg_id]
call_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
getLogger("click_extra").debug(f"Call basicConfig({call_str})")
with patch.object(logging, "StreamHandler", ExtraStreamHandler):
with patch.object(logging, "Formatter", ExtraFormatter):
basicConfig(**kwargs)
[docs]
def new_extra_logger(
name: str = logging.root.name,
*,
handler_class: type[THandler] = ExtraStreamHandler, # type: ignore[assignment]
formatter_class: type[TFormatter] = ExtraFormatter, # type: ignore[assignment]
propagate: bool = False,
force: bool = True,
**kwargs,
) -> Logger:
"""Setup a logger in the style of Click Extra.
It is a wrapper around :func:`extraBasicConfig`, and takes the same keywords arguments.
But it also:
- Fetches the logger to configure or creates a new one if none is provided, by the
``name`` parameter.
- Sets the `logger's propagate
<https://docs.python.org/3/library/logging.html#logging.Logger.propagate>`_
attribute to ``False``.
- Force removal of any existing handlers and formatters attached to the logger
before adding the new default ones. I.e. same as setting
``basicConfig(force=True)``.
- Returns the logger object.
:param name: ID of the logger to setup. If ``None``, Python's ``root``
logger will be used. If a logger with the provided name is not found in the
global registry, a new logger with that name will be created.
:param handler_class: :py:class:`logging.Handler` class that will be used by
:func:`basicConfig` to create new handlers. Defaults to
:py:class:`ExtraStreamHandler`.
:param formatter_class: :py:class:`logging.Formatter` class of the formatter that
will be used by :func:`basicConfig` to setup handlers. Defaults to
:py:class:`ExtraFormatter`.
:param propagate: same as :param:`basicConfig.propagate` and :param:`extraBasicConfig.propagate`. Defaults to ``False``.
:param force: same as :param:`basicConfig.force` and :param:`extraBasicConfig.force`. Defaults to ``True``.
:param kwargs: Any other keyword parameters supported by :func:`basicConfig` and :func:`extraBasicConfig`.
"""
if name == logging.root.name:
logger = logging.root
root_logger_patch = nullcontext()
else:
logger = getLogger(name) # type: ignore[assignment]
logger.propagate = propagate
root_logger_patch = patch.object( # type: ignore[assignment]
logging,
"root",
logger,
)
with root_logger_patch:
extraBasicConfig(force=force, **kwargs)
return logger
[docs]
class VerbosityOption(ExtraOption):
"""A pre-configured ``--verbosity``/``-v`` option.
Sets the level of the provided logger. If no logger is provided, sets the level of
the global ``root`` logger.
The selected verbosity level name is made available in the context in
``ctx.meta["click_extra.verbosity"]``.
.. important::
The internal ``click_extra`` logger level will be aligned to the value set via
this option.
"""
logger_name: str
"""The ID of the logger to set the level to.
This will be provided to `logging.getLogger
<https://docs.python.org/3/library/logging.html?highlight=getlogger#logging.getLogger>`_
method to fetch the logger object, and as such, can be a dot-separated string to
build hierarchical loggers.
"""
@property
def all_loggers(self) -> Generator[Logger, None, None]:
"""Returns the list of logger IDs affected by the verbosity option.
Will returns Click Extra's internal logger first, then the option's custom
logger.
"""
for name in ("click_extra", self.logger_name):
yield getLogger(name)
[docs]
def reset_loggers(self) -> None:
"""Forces all loggers managed by the option to be reset to the default level.
Reset loggers in reverse order to ensure the internal logger is reset last.
.. danger::
Resetting loggers is extremely important for unittests. Because they're
global, loggers have tendency to leak and pollute their state between
multiple test calls.
"""
for logger in list(self.all_loggers)[::-1]:
getLogger("click_extra").debug(f"Reset {logger} to {DEFAULT_LEVEL_NAME}.")
logger.setLevel(DEFAULT_LEVEL)
[docs]
def set_levels(self, ctx: Context, param: Parameter, value: str) -> None:
"""Set level of all loggers configured on the option.
Save the verbosity level name in the context.
Also prints the chosen value as a debug message via the internal
``click_extra`` logger.
"""
ctx.meta["click_extra.verbosity"] = value
for logger in self.all_loggers:
logger.setLevel(LOG_LEVELS[value])
getLogger("click_extra").debug(f"Set {logger} to {value}.")
ctx.call_on_close(self.reset_loggers)
def __init__(
self,
param_decls: Sequence[str] | None = None,
default_logger: Logger | str = logging.root.name,
default: str = DEFAULT_LEVEL_NAME,
metavar="LEVEL",
type=Choice(LOG_LEVELS, case_sensitive=False), # type: ignore[arg-type]
expose_value=False,
help=_("Either {log_levels}.").format(log_levels=", ".join(LOG_LEVELS)),
is_eager=True,
**kwargs,
) -> None:
"""Set up the verbosity option.
:param default_logger: If a ``logging.Logger`` object is provided, that's
the instance to which we will set the level to. If the parameter is a string
and is found in the global registry, we will use it as the logger's ID.
Otherwise, we will create a new logger with Click Extra's ``new_extra_logger``
Default to the global ``root`` logger.
"""
if not param_decls:
param_decls = ("--verbosity", "-v")
# A logger object has been provided, fetch its name.
if isinstance(default_logger, Logger):
self.logger_name = default_logger.name
# Use the provided string if it is found in the registry.
elif default_logger in Logger.manager.loggerDict:
self.logger_name = default_logger
# Create a new logger with Click Extra's default configuration.
else:
logger = new_extra_logger(name=default_logger)
self.logger_name = logger.name
kwargs.setdefault("callback", self.set_levels)
super().__init__(
param_decls=param_decls,
default=default,
metavar=metavar,
type=type,
expose_value=expose_value,
help=help,
is_eager=is_eager,
**kwargs,
)