Source code for tests.test_commands

# 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.
"""Test defaults of our custom commands, as well as their customizations and attached
options, and how they interact with each others."""

from __future__ import annotations

import inspect
import os
import sys
from contextlib import nullcontext
from textwrap import dedent

import click
import cloup
import pytest

import click_extra
from click_extra import (
    ExtraVersionOption,
    LazyGroup,
    command,
    echo,
    group,
    option,
    option_group,
    pass_context,
    version_option,
)
from click_extra.commands import default_extra_params
from click_extra.pytest import (
    command_decorators,
    default_debug_uncolored_log_end,
    default_debug_uncolored_log_start,
    default_options_colored_help,
    default_options_uncolored_help,
)


[docs] @pytest.mark.once def test_module_root_declarations(): """Verify ``click_extra.__all__`` is a superset of click and cloup. Sort order is enforced by ``ruff`` (RUF022). """ click_extra_members = set(click_extra.__all__) click_members = { name for name, member in inspect.getmembers(click) if not name.startswith("_") and not inspect.ismodule(member) } assert click_members <= click_extra_members cloup_members = {m for m in cloup.__all__ if not m.startswith("_")} assert cloup_members <= click_extra_members
[docs] @pytest.fixture def all_command_cli(): """A CLI that is mixing all variations and flavors of subcommands.""" def versioned_extra_params(): params = default_extra_params() for p in params: if isinstance(p, ExtraVersionOption): p.version = "2021.10.08" return params @group(params=versioned_extra_params) def command_cli1(): echo("It works!") @command_cli1.command() def default_subcommand(): echo("Run default subcommand...") @command def click_extra_subcommand(): echo("Run click-extra subcommand...") @cloup.command() def cloup_subcommand(): echo("Run cloup subcommand...") @click.command def click_subcommand(): echo("Run click subcommand...") command_cli1.section( "Subcommand group", click_extra_subcommand, cloup_subcommand, click_subcommand, ) return command_cli1
help_screen = ( r"Usage: command-cli1 \[OPTIONS\] COMMAND \[ARGS\]\.\.\.\n" r"\n" r"Options:\n" rf"{default_options_uncolored_help}" r"\n" r"Subcommand group:\n" r" click-extra-subcommand\n" r" cloup-subcommand\n" r" click-subcommand\n" r"\n" r"Other commands:\n" r" default-subcommand\n" )
[docs] def test_unknown_option(invoke, all_command_cli): result = invoke(all_command_cli, "--blah") assert not result.stdout assert "Error: No such option: --blah" in result.stderr assert result.exit_code == 2
[docs] def test_unknown_command(invoke, all_command_cli): result = invoke(all_command_cli, "blah") assert not result.stdout assert "Error: No such command 'blah'." in result.stderr assert result.exit_code == 2
[docs] def test_required_command(invoke, all_command_cli, assert_output_regex): result = invoke(all_command_cli, "--verbosity", "DEBUG", color=False) # In debug mode, the version is always printed. assert not result.stdout assert_output_regex( result.stderr, ( rf"{default_debug_uncolored_log_start}" rf"{default_debug_uncolored_log_end}" r"Usage: command-cli1 \[OPTIONS\] COMMAND \[ARGS\]\.\.\.\n" r"Try 'command-cli1 --help' for help\.\n" r"\n" r"Error: Missing command\.\n" ), ) assert result.exit_code == 2
[docs] @pytest.mark.parametrize(("param", "exit_code"), ((None, 2), ("-h", 0), ("--help", 0))) def test_group_help(invoke, all_command_cli, param, exit_code, assert_output_regex): result = invoke(all_command_cli, param, color=False) assert "It works!" not in result.stdout if exit_code == 2: assert_output_regex(result.stderr, help_screen) else: assert_output_regex(result.stdout, help_screen) assert not result.stderr assert result.exit_code == exit_code
[docs] @pytest.mark.parametrize( ("params", "exit_code", "expect_help", "expect_empty_stderr"), ( (("--help", "--version"), 0, True, True), # --version takes precedence over --help. (("--version", "--help"), 0, False, True), (("--help", "blah"), 0, True, True), (("--help", "--verbosity", "DEBUG"), 0, True, True), # stderr will contain DEBUG log messages. (("--verbosity", "DEBUG", "--help"), 0, True, False), (("--help", "--config", "random.toml"), 0, True, True), # Config file does not exist and stderr will contain the error message. (("--config", "random.toml", "--help"), 2, False, False), ), ) def test_help_eagerness( invoke, all_command_cli, params, exit_code, expect_help, expect_empty_stderr, assert_output_regex, ): """See: https://click.palletsprojects.com/en/stable/click-concepts/#callback-evaluation-order """ result = invoke(all_command_cli, params, color=False) assert "It works!" not in result.stdout if expect_help: assert_output_regex(result.stdout, help_screen) elif result.stdout: with pytest.raises(AssertionError): assert_output_regex(result.stdout, help_screen) if expect_empty_stderr: assert not result.stderr else: assert result.stderr assert result.exit_code == exit_code
[docs] def test_help_custom_name(invoke): """Removes the ``-h`` short option as we reserve it for a custom ``-h/--header`` option. See: https://github.com/kdeldycke/mail-deduplicate/issues/762 """ @command(context_settings={"help_option_names": ("--help",)}) @option("-h", "--header", is_flag=True) def cli(header): echo(f"--header is {header}") result = invoke(cli, "--help", color=False) assert "-h, --header" in result.stdout assert "-h, --help" not in result.stdout assert "--help" in result.stdout assert not result.stderr assert result.exit_code == 0
[docs] @pytest.mark.parametrize( "cmd_id", ( "default-subcommand", "click-extra-subcommand", "cloup-subcommand", "click-subcommand", ), ) @pytest.mark.parametrize("param", ("-h", "--help")) def test_subcommand_help(invoke, all_command_cli, cmd_id, param, assert_output_regex): result = invoke(all_command_cli, cmd_id, param) colored_help_header = ( r"It works!\n" r"\x1b\[94m\x1b\[1m\x1b\[4mUsage:\x1b\[0m " rf"\x1b\[97mcommand-cli1 {cmd_id}\x1b\[0m" r" \x1b\[36m\x1b\[2m\[OPTIONS\]\x1b\[0m\n" r"\n" r"\x1b\[94m\x1b\[1m\x1b\[4mOptions:\x1b\[0m\n" ) # Extra sucommands are colored and include all extra options. if cmd_id == "click-extra-subcommand": assert_output_regex( result.stdout, rf"{colored_help_header}{default_options_colored_help}", ) # Default subcommand inherits from extra family and is colored, but does not include # extra options. elif cmd_id == "default-subcommand": assert_output_regex( result.stdout, ( rf"{colored_help_header}" r" \x1b\[36m-h\x1b\[0m, \x1b\[36m--help\x1b\[0m" r" Show this message and exit\.\n" ), ) # Non-extra subcommands are not colored. else: assert result.stdout == dedent( f"""\ It works! Usage: command-cli1 {cmd_id} [OPTIONS] Options: -h, --help Show this message and exit. """, ) assert result.exit_code == 0 assert not result.stderr
[docs] @pytest.mark.parametrize("cmd_id", ("default", "click-extra", "cloup", "click")) def test_subcommand_execution(invoke, all_command_cli, cmd_id): result = invoke(all_command_cli, f"{cmd_id}-subcommand", color=False) assert result.stdout == dedent( f"""\ It works! Run {cmd_id} subcommand... """, ) assert not result.stderr assert result.exit_code == 0
[docs] def test_integrated_version_value(invoke, all_command_cli): result = invoke(all_command_cli, "--version", color=False) assert result.stdout == "command-cli1, version 2021.10.08\n" assert not result.stderr assert result.exit_code == 0
[docs] @pytest.mark.parametrize( "cmd_decorator", command_decorators(no_click=True, no_cloup=True, with_parenthesis=False), ) @pytest.mark.parametrize("param", ("-h", "--help")) def test_colored_bare_help(invoke, cmd_decorator, param): """Extra decorators are always colored. Even when stripped of their default parameters, as reported in: https://github.com/kdeldycke/click-extra/issues/534 https://github.com/kdeldycke/click-extra/pull/543 """ @cmd_decorator(params=None) def bare_cli(): pass result = invoke(bare_cli, param) assert ( "\n" "\x1b[94m\x1b[1m\x1b[4mOptions:\x1b[0m\n" " \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m Show this message and exit.\n" ) in result.stdout assert not result.stderr assert result.exit_code == 0
[docs] def test_duplicate_option(invoke): """ See: - https://kdeldycke.github.io/click-extra/commands.html#change-default-options - https://github.com/kdeldycke/click-extra/issues/232 """ @command @version_option(version="0.1") def cli(): pass result = invoke(cli, "--help", color=False) assert result.stdout.endswith( " --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" ) assert not result.stderr assert result.exit_code == 0
[docs] def test_no_option_leaks_between_subcommands(invoke, assert_output_regex): """As reported in https://github.com/kdeldycke/click-extra/issues/489.""" @click.group def cli(): echo("Run cli...") @command @click.option("--one") def foo(): echo("Run foo...") @command(short_help="Bar subcommand.") @click.option("--two") def bar(): echo("Run bar...") cli.add_command(foo) cli.add_command(bar) result = invoke(cli, "--help", color=False) assert result.stdout == dedent( """\ Usage: cli [OPTIONS] COMMAND [ARGS]... Options: --help Show this message and exit. Commands: bar Bar subcommand. foo """, ) assert not result.stderr assert result.exit_code == 0 result = invoke(cli, "foo", "--help", color=False) assert_output_regex( result.stdout, ( r"Run cli\.\.\.\n" r"Usage: cli foo \[OPTIONS\]\n" r"\n" r"Options:\n" r" --one TEXT\n" rf"{default_options_uncolored_help}" ), ) assert not result.stderr assert result.exit_code == 0 result = invoke(cli, "bar", "--help", color=False) assert_output_regex( result.stdout, ( r"Run cli\.\.\.\n" r"Usage: cli bar \[OPTIONS\]\n" r"\n" r"Options:\n" r" --two TEXT\n" rf"{default_options_uncolored_help}" ), ) assert not result.stderr assert result.exit_code == 0
[docs] def test_option_group_integration(invoke, assert_output_regex): # Mix regular and grouped options @group @option_group( "Group 1", click.option("-a", "--opt1"), option("-b", "--opt2"), ) @click.option("-c", "--opt3") @option("-d", "--opt4") def command_cli2(opt1, opt2, opt3, opt4): echo("It works!") @command_cli2.command() def default_command(): echo("Run command...") # Remove colors to simplify output comparison. result = invoke(command_cli2, "--help", color=False) assert_output_regex( result.stdout, ( r"Usage: command-cli2 \[OPTIONS\] COMMAND \[ARGS\]\.\.\.\n" r"\n" r"Group 1:\n" r" -a, --opt1 TEXT\n" r" -b, --opt2 TEXT\n" r"\n" r"Other options:\n" r" -c, --opt3 TEXT\n" r" -d, --opt4 TEXT\n" rf"{default_options_uncolored_help}" r"\n" r"Commands:\n" r" default\n" ), ) assert "It works!" not in result.stdout assert not result.stderr assert result.exit_code == 0
[docs] @pytest.mark.parametrize( ("cmd_decorator", "ctx_settings", "expected_help"), ( # Click does not show all envvar in the help screen by default, unless # specifficaly set on an option. ( click.command, {}, " --flag1\n --flag2 [env var: custom2]\n --flag3\n", ), # Click Extra defaults to let each option choose its own show_envvar value. ( command, {}, " --flag1\n" " --flag2 [env var: " + ("CUSTOM2" if os.name == "nt" else "custom2") + ", CLI_FLAG2]\n" " --flag3\n", ), # Click Extra allow bypassing its global show_envvar setting. ( command, {"show_envvar": None}, " --flag1\n" " --flag2 [env var: " + ("CUSTOM2" if os.name == "nt" else "custom2") + ", CLI_FLAG2]\n" " --flag3\n", ), # Click Extra force the show_envvar value on all options. ( command, {"show_envvar": True}, " --flag1 [env var: " + ("CUSTOM1" if os.name == "nt" else "custom1") + ", CLI_FLAG1]\n" " --flag2 [env var: " + ("CUSTOM2" if os.name == "nt" else "custom2") + ", CLI_FLAG2]\n" " --flag3 [env var: " + ("CUSTOM3" if os.name == "nt" else "custom3") + ", CLI_FLAG3]\n", ), ( command, {"show_envvar": False}, " --flag1\n --flag2\n --flag3\n", ), ), ) def test_show_envvar_parameter(invoke, cmd_decorator, ctx_settings, expected_help): @cmd_decorator(context_settings=ctx_settings) @option("--flag1", is_flag=True, envvar=["custom1"]) @option("--flag2", is_flag=True, envvar=["custom2"], show_envvar=True) @option("--flag3", is_flag=True, envvar=["custom3"], show_envvar=False) def cli(): pass # Remove colors to simplify output comparison. result = invoke(cli, "--help", color=False) assert expected_help in result.stdout assert not result.stderr assert result.exit_code == 0
[docs] def test_raw_args(invoke): """Raw args are expected to be scoped in subcommands.""" @group @option("--dummy-flag/--no-flag") @pass_context def my_cli(ctx, dummy_flag): echo("-- Group output --") echo(f"dummy_flag is {dummy_flag!r}") echo(f"Raw parameters: {ctx.meta.get('click_extra.raw_args', [])}") @my_cli.command() @pass_context @option("--int-param", type=int, default=10) def subcommand(ctx, int_param): echo("-- Subcommand output --") echo(f"int_parameter is {int_param!r}") echo(f"Raw parameters: {ctx.meta.get('click_extra.raw_args', [])}") result = invoke(my_cli, "--dummy-flag", "subcommand", "--int-param", "33") assert result.stdout == dedent( """\ -- Group output -- dummy_flag is True Raw parameters: ['--dummy-flag', 'subcommand', '--int-param', '33'] -- Subcommand output -- int_parameter is 33 Raw parameters: ['--int-param', '33'] """, ) assert not result.stderr assert result.exit_code == 0
[docs] @pytest.mark.parametrize( "lazy_cmd_decorator", ( "@click.command", "@click_extra.command", "@cloup.command()", ), ) @pytest.mark.parametrize( "lazy_group_decorator", ( "@click.group(cls=LazyGroup,", "@cloup.group(cls=LazyGroup,", "@click_extra.group(cls=LazyGroup,", ), ) def test_lazy_group(invoke, tmp_path, lazy_cmd_decorator, lazy_group_decorator): """Test extends the `snippet from Click documentation <https://click.palletsprojects.com/en/stable/complex/#using-lazygroup-to-define-a-cli>`_. """ (tmp_path / "foo_cmd.py").write_text( dedent( f""" import click import cloup import click_extra from click import echo, option print("<foo_cmd module loaded>") {lazy_cmd_decorator} @option("--foo-param", default=5) def foo_cli(foo_param): echo(f"foo_param = {{foo_param}}") """ ) ) (tmp_path / "fur_cmd.py").write_text( dedent( f""" import click import cloup import click_extra from click import echo, option print("<fur_cmd module loaded>") {lazy_cmd_decorator} @option("--fur-param", default=7) def fur_cli(fur_param): echo(f"fur_param = {{fur_param}}") """ ) ) (tmp_path / "bar_cmd.py").write_text( dedent( f""" import click import cloup import click_extra from click import echo, option from click_extra import LazyGroup print("<bar_cmd module loaded>") {lazy_group_decorator} lazy_subcommands={{"baz_cmd": "baz_cmd.baz_cli"}}, help="bar command for lazy example.", ) @option("--bar-param", default=11) def bar_cli(bar_param): echo(f"bar_param = {{bar_param}}") """ ) ) (tmp_path / "baz_cmd.py").write_text( dedent( f""" import click import cloup import click_extra from click import echo, option print("<baz_cmd module loaded>") {lazy_cmd_decorator} @option("--baz-param", default=13) def baz_cli(baz_param): echo(f"baz_param = {{baz_param}}") """ ) ) def reset_main_cli(): """Create the main CLI command with lazy subcommands. Also forces a reset of the lazy-loaded module. Else we'll have an issue with ``invoke()`` reusing the same CLI instance, and modules attached to it not getting reloaded because ``LazyGroup`` caches the resolved commands. """ # Remove lazy-loaded modules from sys.modules to force reloading. for module_name in ["foo_cmd", "fur_cmd", "bar_cmd", "baz_cmd"]: sys.modules.pop(module_name, None) @click.group( cls=LazyGroup, lazy_subcommands={ "foo_cmd": "foo_cmd.foo_cli", "fur_cmd": "fur_cmd.fur_cli", "bar_cmd": "bar_cmd.bar_cli", }, help="main CLI command for lazy example.", ) @click.option("--main-param", default=3) def main_cli(main_param): echo(f"main_param = {main_param}") return main_cli help_screen = dedent( """\ Usage: main-cli [OPTIONS] COMMAND [ARGS]... main CLI command for lazy example. Options: --main-param INTEGER [default: 3] -h, --help Show this message and exit. Commands: bar_cmd bar command for lazy example. foo_cmd fur_cmd """ ) # Allow discoverability of the modules implementing the lazy subcommands. sys.path.insert(0, str(tmp_path)) try: main_cli = reset_main_cli() # Calling --help load the modules in a stable order. Also check that the # subcommands are featured in the help screen. But not the nested baz_cmd. result = invoke(main_cli, "--help", color=False) assert result.stdout == ( dedent( """\ <bar_cmd module loaded> <foo_cmd module loaded> <fur_cmd module loaded> """ ) + help_screen ) assert not result.stderr assert result.exit_code == 0 # A second help invocation should not reload already loaded modules. result = invoke(main_cli, "--help", color=False) assert result.stdout == help_screen # Recreate the CLI to reset the lazy-loaded commands cache. main_cli = reset_main_cli() # Check modules are reloaded. result = invoke(main_cli, "--help", color=False) assert result.stdout == ( dedent( """\ <bar_cmd module loaded> <foo_cmd module loaded> <fur_cmd module loaded> """ ) + help_screen ) assert not result.stderr assert result.exit_code == 0 # Execute a lazy subcommand: no module gets loaded because it was already done # in the previous --help invocation. result = invoke(main_cli, "foo_cmd") assert result.stdout == dedent( """\ main_param = 3 foo_param = 5 """ ) assert not result.stderr assert result.exit_code == 0 # Reset the CLI. main_cli = reset_main_cli() # Execute a lazy subcommand: only the invoked module gets lazy loaded. result = invoke(main_cli, "--main-param", "30", "foo_cmd", "--foo-param", "50") assert result.stdout == dedent( """\ <foo_cmd module loaded> main_param = 30 foo_param = 50 """ ) assert not result.stderr assert result.exit_code == 0 # Execute a nested lazy subcommand. result = invoke(main_cli, "bar_cmd", "baz_cmd", "--baz-param", "17") assert result.stdout == dedent( """\ <bar_cmd module loaded> main_param = 3 <baz_cmd module loaded> bar_param = 11 baz_param = 17 """ ) assert not result.stderr assert result.exit_code == 0 finally: sys.path.remove(str(tmp_path))
[docs] def test_decorator_overrides(): """Ensure our decorators are not just alias of Click and Cloup ones.""" assert click_extra.command not in (click.command, cloup.command) assert click_extra.group not in (click.group, cloup.group) assert click_extra.Option not in (click.Option, cloup.Option) assert issubclass(click_extra.Option, click.Option) assert issubclass(click_extra.Option, cloup.Option) assert click_extra.Argument not in (click.Argument, cloup.Argument) assert issubclass(click_extra.Argument, click.Argument) assert issubclass(click_extra.Argument, cloup.Argument) assert click_extra.option not in (click.option, cloup.option) assert click_extra.argument not in (click.argument, cloup.argument) assert click_extra.version_option not in ( click.version_option, cloup.version_option, )
[docs] @pytest.mark.parametrize( ("klass", "should_raise"), ( (click.Command, True), (click.Group, True), (cloup.Command, True), (cloup.Group, True), (click_extra.Command, True), (click_extra.Group, True), (click_extra.ExtraCommand, False), (click_extra.ExtraGroup, False), (str, True), (int, True), ), ) def test_decorator_cls_parameter(klass, should_raise): """Decorators accept custom cls parameters.""" class Custom(klass): pass context = pytest.raises(TypeError) if should_raise else nullcontext() with context: command(cls=Custom)