from collections.abc import Callable
from functools import update_wrapper
from typing import Any, Literal
import click
from rics.logs import DATE_FORMAT, FORMAT_SEC, LoggingSetupHelper, _UserVerbosityLevels
AnyCallable = Callable[..., Any]
Decorator = Callable[[AnyCallable], AnyCallable]
Mode = Literal["forward", "forward_both", "pop", "skip"]
class VerbosityParamType(click.types.IntParamType):
name = "verbosity"
def __init__(self, max: int) -> None:
self._max = max
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> Any:
inv_value: int = super().convert(value, param, ctx)
if inv_value > self._max:
self.fail(f"May be repeated at most {self._max} times (got {value}).", param, ctx)
return inv_value
[docs]
def logging_verbosity_option(
*param_decls: str,
mode: Mode = "pop",
# Helper class params
levels: _UserVerbosityLevels,
format: str = FORMAT_SEC,
datefmt: str = DATE_FORMAT,
# Click params
cls: type[click.Option] | None = None,
**attrs: Any,
) -> Decorator:
"""Add a ``click`` option to a command.
Mode options:
* `forward`: Configure logging, then forward the parameter.
* `forward_both`: Do **not** configure logging. Forward a tuple ``(verbosity, helper)`` instead.
* `pop` (default): Configure logging and remove the parameter.
* `skip`: Does **not** configure logging or create a helper. The parameter is forwarded.
Args:
*param_decls: Positional arguments to the constructor of ``cls``.
mode: Logging setup mode. See above for options.
levels: An iterable of levels, where each level is a dict ``{logger_name: log_level}``.
format: Format string for emitted messages; see :func:`rics.logs.basic_config`.
datefmt: Format string for date/time; see :func:`rics.logs.basic_config`.
cls: Type of :class:`click.Option` to instantiate.
**attrs: Passed as keyword arguments to the constructor of ``cls``.
Defaults are provided for `help`, `count`, and `type`.
Raises:
TypeError: If `attrs` is incompatible with this method.
See Also:
The :func:`click.option` function and :meth:`rics.logs.LoggingSetupHelper` class.
Examples:
Decorating ``click`` commands.
>>> import logging, click
>>> @click.command
>>> @logging_verbosity_option(
... "--verbose", "-v",
... levels=[
... {"rics": "INFO", "id_translation": "WARNING"},
... {"rics": "DEBUG"},
... ],
... )
>>> @click.pass_context
>>> def cli(cxt: click.Context, verbose: int) -> None:
>>> print(verbose)
>>> print(logging.getLogger("rics"))
>>> print(logging.getLogger("id_translation"))
When running this command, passing ``-vv`` increases `rics` verbosity to ``logging.DEBUG``.
Advanced configuration with ``mode="forward_both"``.
>>> import logging, click
>>> from rics.logs import LoggingSetupHelper
>>> @click.command
>>> @logging_verbosity_option(
... "--verbose", "-v",
... mode="forward_both",
... levels=[
... {"rics": "INFO", "id_translation": "WARNING"},
... {"rics": "DEBUG"},
... ],
... )
>>> @click.pass_context
>>> def cli(
... cxt: click.Context,
... verbose: tuple[int, LoggingSetupHelper],
... ) -> None:
>>> verbosity, helper = verbose
>>> helper.configure_logging(verbosity) # 0=logging.root.disabled=True
>>> if verbosity == 0:
>>> import os, sys
>>> sys.stdout = open(os.devnull, "w")
>>> cxt.meta["no_stdout"] = True
In this mode, :meth:`.LoggingSetupHelper.configure_logging()` is not called before invoking ``cli()``. Above is
a dummy program that disables ``sys.stdout`` if verbosity is zero (i.e. ``-v`` is repeated zero times).
"""
helper = LoggingSetupHelper(levels, format=format, datefmt=datefmt)
if "help" not in attrs:
attrs["help"] = _create_help_string(helper.get_level_descriptions())
if "count" in attrs:
raise TypeError(f"{attrs['count']=} is not allowed.")
attrs["count"] = True
if "type" in attrs:
raise TypeError(f"{attrs['type']=} is not allowed.")
param_type = VerbosityParamType(helper.max)
attrs["type"] = param_type
click_option_decorator = click.option(*param_decls, cls=cls, **attrs)
if mode == "skip":
return click_option_decorator
return _create_decorator(helper, click_option_decorator, param_type, mode)
def _create_decorator(
helper: LoggingSetupHelper,
click_decorator: Decorator,
param_type: VerbosityParamType,
mode: Mode,
) -> Decorator:
def get_param_name(func: AnyCallable) -> str:
assert hasattr(func, "__click_params__") # noqa: S101
cp: click.Parameter
for cp in func.__click_params__:
if cp.type is param_type:
assert isinstance(cp.name, str) # noqa: S101
return cp.name
raise RuntimeError(f"Unable to derive verbosity parameter name: {func.__click_params__=}.")
def configure_logging_decorator(func: AnyCallable) -> AnyCallable:
click_func = click_decorator(func) # Sets __click_params__ on func.
param_name = get_param_name(click_func) # Could click_func be a click.Command at this point?
def wrapper(*args: Any, **kwargs: Any) -> Any:
verbosity = kwargs.pop(param_name) if mode == "pop" else kwargs[param_name]
if not isinstance(verbosity, int):
raise TypeError(f"kwargs[{param_name!r}={kwargs[param_name]!r}: expected `int`")
if mode == "forward_both":
kwargs[param_name] = verbosity, helper
return func(*args, **kwargs)
helper.configure_logging(verbosity)
return func(*args, **kwargs)
return update_wrapper(wrapper, click_func)
return configure_logging_decorator
def _create_help_string(descriptions: list[str]) -> str:
"""Create help string."""
lines = [
"Controls application logging to stderr.",
"\n",
"\b\nVerbosity levels:",
" 0 = Logging disabled (default).",
*(f" {verbosity} = {line}" for verbosity, line in enumerate(descriptions, start=1)),
f"Repeat up to {len(descriptions)} times for increased verbosity.",
]
return "\n".join(lines)