diff --git a/changelog.md b/changelog.md index 69d7eeedf..fa9a53e18 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,9 @@ > [!IMPORTANT] > This version is not released yet and is under active development. +- Add a new `--verbose` option on `@extra_command` and `@extra_group` to increase the verbosity level for each additional repetition. +- Add new `@verbose_option` pre-configured decorator. +- Reassign the short `-v` option from `--verbosity` to `--verbose`. - Improve logging documentation. - Align `ExtraStreamHandler` behavior to `logging.StreamHandler`. - Move `stream_handler_class` and `formatter_class` arguments from `new_extra_logger` to `extraBasicConfig`. diff --git a/click_extra/__init__.py b/click_extra/__init__.py index 0789ecdd9..1b79fd1df 100644 --- a/click_extra/__init__.py +++ b/click_extra/__init__.py @@ -60,11 +60,13 @@ table_format_option, telemetry_option, timer_option, + verbose_option, verbosity_option, ) from .logging import ( # noqa: E402 ExtraFormatter, ExtraStreamHandler, + VerboseOption, VerbosityOption, extraBasicConfig, new_extra_logger, @@ -191,6 +193,8 @@ "unstyle", # noqa: F405 "UsageError", # noqa: F405 "UUID", # noqa: F405 + "verbose_option", # noqa: F405 + "VerboseOption", # noqa: F405 "verbosity_option", # noqa: F405 "VerbosityOption", # noqa: F405 "version_option", # noqa: F405 diff --git a/click_extra/commands.py b/click_extra/commands.py index 739b146b7..2a650d80b 100644 --- a/click_extra/commands.py +++ b/click_extra/commands.py @@ -30,7 +30,7 @@ from . import Command, Group, Option from .colorize import ColorOption, ExtraHelpColorsMixin, HelpExtraFormatter from .config import ConfigOption -from .logging import VerbosityOption +from .logging import VerboseOption, VerbosityOption from .parameters import ( ExtraOption, ShowParamsOption, @@ -146,7 +146,8 @@ def default_extra_params() -> list[Option]: behavior and value of the other options. #. ``--color``, ``--ansi`` / ``--no-color``, ``--no-ansi`` #. ``--show-params`` - #. ``-v``, ``--verbosity LEVEL`` + #. ``--verbosity LEVEL`` + #. ``-v``, ``--verbose`` #. ``--version`` #. ``-h``, ``--help`` .. attention:: @@ -181,6 +182,7 @@ def default_extra_params() -> list[Option]: ConfigOption(), ShowParamsOption(), VerbosityOption(), + VerboseOption(), ExtraVersionOption(), # click.decorators.HelpOption(), ] diff --git a/click_extra/decorators.py b/click_extra/decorators.py index 6054e927f..45cdfb6c4 100644 --- a/click_extra/decorators.py +++ b/click_extra/decorators.py @@ -23,7 +23,7 @@ from .colorize import ColorOption from .commands import DEFAULT_HELP_NAMES, ExtraCommand, ExtraGroup, default_extra_params from .config import ConfigOption -from .logging import VerbosityOption +from .logging import VerboseOption, VerbosityOption from .parameters import ShowParamsOption from .tabulate import TableFormatOption from .telemetry import TelemetryOption @@ -120,4 +120,5 @@ def decorator(*args, **kwargs): table_format_option = decorator_factory(dec=cloup.option, cls=TableFormatOption) telemetry_option = decorator_factory(dec=cloup.option, cls=TelemetryOption) timer_option = decorator_factory(dec=cloup.option, cls=TimerOption) +verbose_option = decorator_factory(dec=cloup.option, cls=VerboseOption) verbosity_option = decorator_factory(dec=cloup.option, cls=VerbosityOption) diff --git a/click_extra/logging.py b/click_extra/logging.py index 9aa15d0b8..9e59717bb 100644 --- a/click_extra/logging.py +++ b/click_extra/logging.py @@ -39,10 +39,11 @@ from unittest.mock import patch import click +from click.types import IntRange from . import Choice, Context, Parameter from .colorize import default_theme -from .parameters import ExtraOption +from .parameters import ExtraOption, search_params if TYPE_CHECKING: from collections.abc import Generator, Iterable, Sequence @@ -314,18 +315,14 @@ def new_extra_logger( return logger -class VerbosityOption(ExtraOption): - """A pre-configured ``--verbosity``/``-v`` option. +class ExtraVerbosity(ExtraOption): + """A base class implementing all the common halpers to manipulated logger's verbosity. - Sets the level of the provided logger. If no logger is provided, sets the level of - the global ``root`` logger. + It is not intended to be used as-is, but is a central place to reconcile the verbosity level + to be set by the competing verbosity options implemented below: - 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. + - ``--verbosity`` + - ``--verbose``/``-v`` """ logger_name: str @@ -367,6 +364,19 @@ def set_levels(self, ctx: Context, param: Parameter, value: str) -> None: Also prints the chosen value as a debug message via the internal ``click_extra`` logger. """ + # If any of the verbosity-related option has already set the level, do not alter it. + # So in a way, this property in the context serves as a kind of global state shared by + # all verbosity options. + + current_level = ctx.meta.get("click_extra.verbosity") + + if current_level: + levels = tuple(LOG_LEVELS) + current_level_index = levels.index(current_level) + new_level_index = levels.index(value) + if new_level_index <= current_level_index: + return + ctx.meta["click_extra.verbosity"] = value for logger in self.all_loggers: @@ -375,6 +385,47 @@ def set_levels(self, ctx: Context, param: Parameter, value: str) -> None: ctx.call_on_close(self.reset_loggers) + def __init__( + self, + param_decls: Sequence[str] | None = None, + default_logger: Logger | str = logging.root.name, + **kwargs, + ) -> None: + # 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. + # XXX That's also the case in which the root logger will fall into, because as + # a special case, it is not registered in Logger.manager.loggerDict. + 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, + **kwargs, + ) + + +class VerbosityOption(ExtraVerbosity): + """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. + """ + def __init__( self, param_decls: Sequence[str] | None = None, @@ -396,25 +447,11 @@ def __init__( 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. - # XXX That's also the case in which the root logger will fall into, because as - # a special case, it is not registered in Logger.manager.loggerDict. - else: - logger = new_extra_logger(name=default_logger) - self.logger_name = logger.name - - kwargs.setdefault("callback", self.set_levels) + param_decls = ("--verbosity",) super().__init__( param_decls=param_decls, + default_logger=default_logger, default=default, metavar=metavar, type=type, @@ -423,3 +460,81 @@ def __init__( is_eager=is_eager, **kwargs, ) + + +class VerboseOption(ExtraVerbosity): + """Increase the log level of :class:`VerbosityOption` by a number of step levels. + + By default, it will not affect the level set by the ``--verbosity`` parameter. + + But if ``-v`` is passed, then it will increase the ``--verbosity``'s level by one level. + The option can be provided multiple times by the user. So if ``-vv`` is passed, then the level + of ``--verbosity`` will be increase by 2 steps. + + With ``--verbosity`` defaults set to ``WARNING``: + + - ``-v`` will increase the level to ``INFO``. + - ``-vv`` will increase the level to ``DEBUG``. + - Any number of repetition above that point will be set to the maximum level, so for example ``-vvvvv`` will be capped at ``DEBUG``. + + If default's ``--verbosity`` is changed to a lower level than ``WARNING``, ``-v`` effect will change its base level accordingly. + """ + + def set_levels(self, ctx: Context, param: Parameter, value: int) -> None: + ctx.meta["click_extra.verbose"] = value + + # No -v option has been called, skip meddling with log levels. + if value == 0: + return + + levels = tuple(LOG_LEVELS) + + # Get default verbosity from the --verbosity option. + verbosity_option = search_params( + ctx.command.params, VerbosityOption, include_subclasses=False + ) + # If no --verbosity option found, it's because we are use the --verbose option alone. So defaults to + # the global default. + default_level = ( + verbosity_option.default if verbosity_option else DEFAULT_LEVEL_NAME + ) + + default_level_index = levels.index(default_level) + + # Cap new index to the last, verbosier level. + new_level_index = min(default_level_index + value, len(levels) - 1) + new_level = levels[new_level_index] + + super().set_levels(ctx, param, new_level) + + getLogger("click_extra").debug( + f"Increased log verbosity by {value} levels: from {default_level} to {new_level}." + ) + + def __init__( + self, + param_decls: Sequence[str] | None = None, + count: bool = True, + expose_value=False, + help=_( + f"Increase the default {DEFAULT_LEVEL_NAME} verbosity by one level for each additional repetition of the option." + ), + is_eager=True, + **kwargs, + ) -> None: + if not param_decls: + param_decls = ("--verbose", "-v") + + # Force type and default to align them with counting option original behavior: + # https://github.com/pallets/click/blob/5dd628854c0b61bbdc07f22004c5da8fa8ee9481/src/click/core.py#L2612-L2618 + kwargs["type"] = IntRange(min=0) + kwargs["default"] = 0 + + super().__init__( + param_decls=param_decls, + count=count, + expose_value=expose_value, + help=help, + is_eager=is_eager, + **kwargs, + ) diff --git a/click_extra/parameters.py b/click_extra/parameters.py index e31cdac67..737b4e619 100644 --- a/click_extra/parameters.py +++ b/click_extra/parameters.py @@ -142,16 +142,25 @@ def all_envvars( def search_params( params: Iterable[click.Parameter], klass: type[click.Parameter], + include_subclasses: bool = True, unique: bool = True, ) -> list[click.Parameter] | click.Parameter | None: """Search a particular class of parameter in a list and return them. :param params: list of parameter instances to search in. :param klass: the class of the parameters to look for. + :param include_subclasses: if ``True``, includes in the results all parameters subclassing + the provided ``klass``. If ``False``, only matches parameters which are strictly instances of ``klass``. + Defaults to ``True``. :param unique: if ``True``, raise an error if more than one parameter of the - provided ``klass`` is found. + provided ``klass`` is found. Defaults to ``True``. """ - param_list = [p for p in params if isinstance(p, klass)] + param_list = [ + p + for p in params + if (include_subclasses and isinstance(p, klass)) + or (not include_subclasses and p.__class__ is klass) + ] if not param_list: return None if unique: diff --git a/click_extra/pytest.py b/click_extra/pytest.py index f1228f2dc..3a2e3f6d8 100644 --- a/click_extra/pytest.py +++ b/click_extra/pytest.py @@ -206,8 +206,11 @@ def _create_config(filename: str | Path, content: str) -> Path: r" \S+\.{toml,yaml,yml,json,ini,xml}\]\n" r" --show-params Show all CLI parameters, their provenance, defaults\n" r" and value, then exit.\n" - r" -v, --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.\n" + r" --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.\n" r" \[default: WARNING\]\n" + r" -v, --verbose Increase the default WARNING verbosity by one level\n" + r" for each additional repetition of the option.\n" + r" \[default: 0\]\n" r" --version Show the version and exit.\n" r" -h, --help Show this message and exit.\n" ) @@ -236,12 +239,17 @@ def _create_config(filename: str | Path, content: str) -> Path: r" \x1b\[36m--show-params\x1b\[0m" r" Show all CLI parameters, their provenance, defaults\n" r" and value, then exit.\n" - r" \x1b\[36m-v\x1b\[0m, \x1b\[36m--verbosity\x1b\[0m" - r" \x1b\[36m\x1b\[2mLEVEL\x1b\[0m" + r" \x1b\[36m--verbosity\x1b\[0m" + r" \x1b\[36m\x1b\[2mLEVEL\x1b\[0m " r" Either \x1b\[35mCRITICAL\x1b\[0m, \x1b\[35mERROR\x1b\[0m, " r"\x1b\[35mWARNING\x1b\[0m, \x1b\[35mINFO\x1b\[0m, \x1b\[35mDEBUG\x1b\[0m.\n" r" \x1b\[2m\[\x1b\[0m\x1b\[2mdefault: " r"\x1b\[0m\x1b\[32m\x1b\[2m\x1b\[3mWARNING\x1b\[0m\x1b\[2m\]\x1b\[0m\n" + r" \x1b\[36m-v\x1b\[0m, \x1b\[36m--verbose\x1b\[0m" + r" Increase the default \x1b\[35mWARNING\x1b\[0m verbosity by one level\n" + r" for each additional repetition of the option.\n" + r" \x1b\[2m\[\x1b\[0m\x1b\[2mdefault: " + r"\x1b\[0m\x1b\[32m\x1b\[2m\x1b\[3m0\x1b\[0m\x1b\[2m\]\x1b\[0m\n" r" \x1b\[36m--version\x1b\[0m Show the version and exit.\n" r" \x1b\[36m-h\x1b\[0m, \x1b\[36m--help\x1b\[0m" r" Show this message and exit.\n" @@ -258,6 +266,15 @@ def _create_config(filename: str | Path, content: str) -> Path: ) +default_debug_colored_verbose_log = ( + r"\x1b\[34mdebug\x1b\[0m: Increased log verbosity " + r"by \d+ levels: from WARNING to [A-Z]+.\n" +) +default_debug_uncolored_verbose_log = ( + r"debug: Increased log verbosity by \d+ levels: from WARNING to [A-Z]+.\n" +) + + default_debug_uncolored_config = ( r"debug: Load configuration matching .+\*\.{toml,yaml,yml,json,ini,xml}\n" r"debug: Pattern is not an URL: search local file system.\n" diff --git a/tests/test_commands.py b/tests/test_commands.py index d2e8690d6..80e45fcd0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -319,8 +319,11 @@ def cli(): result = invoke(cli, "--help", color=False) assert result.exit_code == 0 assert result.stdout.endswith( - " -v, --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.\n" + " --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.\n" " [default: WARNING]\n" + " -v, --verbose Increase the default WARNING verbosity by one level\n" + " for each additional repetition of the option.\n" + " [default: 0]\n" " --version Show the version and exit.\n" " --version Show the version and exit.\n" " -h, --help Show this message and exit.\n" diff --git a/tests/test_logging.py b/tests/test_logging.py index 80df6c3a8..ae8ca64ea 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -26,7 +26,7 @@ from pytest_cases import parametrize from click_extra import echo -from click_extra.decorators import extra_command, verbosity_option +from click_extra.decorators import extra_command, verbose_option, verbosity_option from click_extra.logging import ( DEFAULT_LEVEL, DEFAULT_LEVEL_NAME, @@ -37,11 +37,14 @@ ) from click_extra.pytest import ( command_decorators, + default_debug_colored_config, default_debug_colored_log_end, - default_debug_colored_log_start, default_debug_colored_logging, + default_debug_colored_verbose_log, + default_debug_colored_version_details, default_debug_uncolored_log_end, default_debug_uncolored_logging, + default_debug_uncolored_verbose_log, ) from .conftest import skip_windows_colors @@ -65,26 +68,82 @@ def test_root_logger_defaults(): assert logging._levelToName[logging.root.level] == DEFAULT_LEVEL_NAME -@pytest.mark.parametrize("level", LOG_LEVELS.keys()) +@pytest.mark.parametrize( + ("args", "expected_level"), + ( + # Default level when no option is provided. + (None, "WARNING"), + # Test all --verbosity levels. + (("--verbosity", "CRITICAL"), "CRITICAL"), + (("--verbosity", "ERROR"), "ERROR"), + (("--verbosity", "WARNING"), "WARNING"), + (("--verbosity", "INFO"), "INFO"), + (("--verbosity", "DEBUG"), "DEBUG"), + # Repeating -v options increases the default level. + (("-v",), "INFO"), + (("--verbose",), "INFO"), + (("-vv",), "DEBUG"), + (("-v", "-v"), "DEBUG"), + (("--verbose", "--verbose"), "DEBUG"), + (("-vvv",), "DEBUG"), + (("-v", "-v", "-v"), "DEBUG"), + (("--verbose", "--verbose", "--verbose"), "DEBUG"), + (("-vvvvvvvvvvvvvv",), "DEBUG"), + (("-vv", "-v", "-vvvvvvvvvvv"), "DEBUG"), + (("-vv", "-v", "--verbose", "-vvvvvvvvvvv"), "DEBUG"), + # Equivalent levels don't conflicts. + (("--verbosity", "INFO", "-v"), "INFO"), + (("--verbosity", "DEBUG", "-vv"), "DEBUG"), + # -v is higher level and takes precedence. + (("--verbosity", "CRITICAL", "-v"), "INFO"), + (("--verbosity", "CRITICAL", "-vv"), "DEBUG"), + # --verbosity is higher level and takes precedence. + (("--verbosity", "DEBUG", "-v"), "DEBUG"), + ), +) # TODO: test extra_group -def test_integrated_verbosity_option(invoke, level): +def test_integrated_verbosity_options(invoke, args, expected_level): @extra_command def logging_cli3(): echo("It works!") - result = invoke(logging_cli3, "--verbosity", level, color=True) + result = invoke(logging_cli3, args, color=True) assert result.exit_code == 0 assert result.stdout == "It works!\n" - if level == "DEBUG": - assert re.fullmatch( - default_debug_colored_log_start + default_debug_colored_log_end, - result.stderr, + if expected_level == "DEBUG": + debug_log = default_debug_colored_logging + if any(a for a in args if a.startswith("-v") or a == "--verbose"): + debug_log += default_debug_colored_verbose_log + debug_log += ( + default_debug_colored_config + + default_debug_colored_version_details + + default_debug_colored_log_end ) + assert re.fullmatch(debug_log, result.stderr) else: assert not result.stderr -def test_custom_option_name(invoke): +@pytest.mark.parametrize( + "args", + ( + # Long option. + ("--blah", "DEBUG"), + # Short option. + ("-B", "DEBUG"), + # Duplicate options. + ("--blah", "DEBUG", "--blah", "DEBUG"), + ("-B", "DEBUG", "-B", "DEBUG"), + ("--blah", "DEBUG", "-B", "DEBUG"), + ("-B", "DEBUG", "--blah", "DEBUG"), + # Duplicate options with different levels: the last always win. + ("--blah", "INFO", "-B", "DEBUG"), + ("-B", "INFO", "--blah", "DEBUG"), + # ("--blah", "DEBUG", "-B", "INFO"), + # ("-B", "DEBUG", "--blah", "INFO"), + ), +) +def test_custom_verbosity_option_name(invoke, args): param_names = ("--blah", "-B") @click.command @@ -93,25 +152,56 @@ def awesome_app(): root_logger = logging.getLogger() root_logger.debug("my debug message.") - for name in param_names: - result = invoke(awesome_app, name, "DEBUG", color=False) - assert result.exit_code == 0 - assert not result.stdout - assert re.fullmatch( - ( - rf"{default_debug_uncolored_logging}" - r"debug: my debug message\.\n" - rf"{default_debug_uncolored_log_end}" - ), - result.stderr, - ) + result = invoke(awesome_app, args, color=False) + assert result.exit_code == 0 + assert not result.stdout + assert re.fullmatch( + default_debug_uncolored_logging + + r"debug: my debug message\.\n" + + default_debug_uncolored_log_end, + result.stderr, + ) + + +@pytest.mark.parametrize( + "args", + ( + # Short option. + ("-BB",), + ("-B", "-B"), + # Long option. + ("--blah", "--blah"), + # Duplicate options. + ("--blah", "-B"), + ("-B", "--blah"), + ), +) +def test_custom_verbose_option_name(invoke, args): + param_names = ("--blah", "-B") + + @click.command + @verbose_option(*param_names) + def awesome_app(): + root_logger = logging.getLogger() + root_logger.debug("my debug message.") + + result = invoke(awesome_app, args, color=False) + assert result.exit_code == 0 + assert not result.stdout + assert re.fullmatch( + default_debug_uncolored_logging + + default_debug_uncolored_verbose_log + + r"debug: my debug message\.\n" + + default_debug_uncolored_log_end, + result.stderr, + ) @pytest.mark.parametrize( ("cmd_decorator", "cmd_type"), command_decorators(with_types=True), ) -def test_unrecognized_verbosity(invoke, cmd_decorator, cmd_type): +def test_unrecognized_verbosity_level(invoke, cmd_decorator, cmd_type): @cmd_decorator @verbosity_option def logging_cli1(): @@ -126,7 +216,7 @@ def logging_cli1(): assert result.stderr == ( f"Usage: logging-cli1 [OPTIONS]{group_help}\n" "Try 'logging-cli1 --help' for help.\n\n" - "Error: Invalid value for '--verbosity' / '-v': " + "Error: Invalid value for '--verbosity': " "'random' is not one of 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'.\n" ) @@ -138,13 +228,26 @@ def logging_cli1(): command_decorators(no_groups=True, no_extra=True), ) @parametrize("option_decorator", (verbosity_option, verbosity_option())) -@pytest.mark.parametrize("level", LOG_LEVELS.keys()) -def test_default_logger(invoke, cmd_decorator, option_decorator, level): +@pytest.mark.parametrize( + ("args", "expected_level"), + ( + (None, "WARNING"), + (("--verbosity", "CRITICAL"), "CRITICAL"), + (("--verbosity", "ERROR"), "ERROR"), + (("--verbosity", "WARNING"), "WARNING"), + (("--verbosity", "INFO"), "INFO"), + (("--verbosity", "DEBUG"), "DEBUG"), + ), +) +def test_standalone_option_default_logger( + invoke, cmd_decorator, option_decorator, args, expected_level +): """Checks: + - option affect log level - the default logger is ``root`` - the default logger message format - level names are colored - - log level is propagated to all other loggers. + - log level is propagated to all other loggers """ @cmd_decorator @@ -163,7 +266,7 @@ def logging_cli2(): logging.error("my error message.") logging.critical("my critical message.") - result = invoke(logging_cli2, "--verbosity", level, color=True) + result = invoke(logging_cli2, args, color=True) assert result.exit_code == 0 assert result.stdout == "It works!\n" @@ -193,10 +296,12 @@ def logging_cli2(): r"\x1b\[31merror\x1b\[0m: my error message.\n", r"\x1b\[31m\x1b\[1mcritical\x1b\[0m: my critical message.\n", ) - level_index = {index: level for level, index in enumerate(LOG_LEVELS)}[level] + level_index = {index: level for level, index in enumerate(LOG_LEVELS)}[ + expected_level + ] log_records = r"".join(messages[-level_index - 1 :]) - if level == "DEBUG": + if expected_level == "DEBUG": log_records += default_debug_colored_log_end assert re.fullmatch(log_records, result.stderr) diff --git a/tests/test_parameters.py b/tests/test_parameters.py index ed806cfc0..13239554d 100644 --- a/tests/test_parameters.py +++ b/tests/test_parameters.py @@ -615,10 +615,24 @@ def show_params_cli(int_param1, int_param2, hidden_param, custom_param): False, "DEFAULT", ), + ( + "show-params-cli.verbose", + "click_extra.logging.VerboseOption", + "-v, --verbose", + "click.types.IntRange", + "int", + "✘", + "✘", + "✓", + "SHOW_PARAMS_CLI_VERBOSE", + "0", + "0", + "DEFAULT", + ), ( "show-params-cli.verbosity", "click_extra.logging.VerbosityOption", - "-v, --verbosity LEVEL", + "--verbosity LEVEL", "click.types.Choice", "str", "✘",