# 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.
"""Tests for the CLI wrapper feature."""
from __future__ import annotations
from pathlib import Path
import click
import pytest
from click_extra.cli import demo
from click_extra.cli_wrapper import (
_config_args_for_target,
resolve_target,
resolve_target_command,
unpatch_click,
wrap,
)
from click_extra.commands import ColorizedCommand, ColorizedGroup
from click_extra.context import Context
from click_extra.highlight import _HelpColorsMixin
from click_extra.testing import CliRunner
GREET_SCRIPT = (
"import click\n"
"\n"
"@click.command()\n"
'@click.option("--name", default="World", help="Name to greet.")\n'
"def hello(name):\n"
' """Greet someone."""\n'
' click.echo(f"Hello, {name}")\n'
"\n"
'if __name__ == "__main__":\n'
" hello()\n"
)
"""Plain ``@click.command()`` script: patched via decorator defaults."""
CUSTOM_CLS_SCRIPT = (
"import click\n"
"\n"
"class RecipeGroup(click.Group):\n"
' """Custom group like Flask\'s FlaskGroup."""\n'
"\n"
"@click.command(cls=RecipeGroup)\n"
"def kitchen():\n"
' """Manage recipes and ingredients."""\n'
"\n"
"@kitchen.command()\n"
'@click.option("--servings", default=4, help="Number of servings.")\n'
"def bake(servings):\n"
' """Bake a cake."""\n'
' click.echo(f"Baking for {servings}")\n'
"\n"
'if __name__ == "__main__":\n'
" kitchen()\n"
)
"""Script with explicit ``cls=RecipeGroup``: patched via method patching."""
MULTI_OPTION_SCRIPT = (
"import click\n"
"\n"
"@click.command()\n"
'@click.option("--city", default="Paris", help="City name.")\n'
'@click.option("--unit", default="celsius", help="Temperature unit.")\n'
'@click.option("--verbose", is_flag=True, help="Show details.")\n'
"def weather(city, unit, verbose):\n"
' """Check the weather."""\n'
' msg = f"{city}: 22 {unit}"\n'
" if verbose:\n"
' msg += " (detailed)"\n'
" click.echo(msg)\n"
"\n"
'if __name__ == "__main__":\n'
" weather()\n"
)
"""Script with multiple options for config passthrough tests."""
@pytest.fixture(autouse=True)
def _restore_click():
"""Undo any monkey-patching after each test to prevent cross-contamination."""
yield
unpatch_click()
[docs]
@pytest.fixture
def runner():
"""CLI runner for wrapper tests."""
return CliRunner()
[docs]
@pytest.fixture
def greet_script(tmp_path):
"""A minimal Click CLI script for wrapping tests."""
script = tmp_path / "greet.py"
script.write_text(GREET_SCRIPT)
return str(script)
[docs]
@pytest.fixture
def custom_cls_script(tmp_path):
"""A Click CLI with explicit ``cls=CustomGroup`` (like Flask's FlaskGroup)."""
script = tmp_path / "kitchen.py"
script.write_text(CUSTOM_CLS_SCRIPT)
return str(script)
[docs]
@pytest.fixture
def weather_script(tmp_path):
"""A Click CLI with multiple options for config tests."""
script = tmp_path / "weather.py"
script.write_text(MULTI_OPTION_SCRIPT)
return str(script)
[docs]
@pytest.fixture
def create_config(tmp_path):
"""Produce a temporary configuration file."""
def _create_config(filename, content):
config_path = tmp_path / filename
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(content, encoding="utf-8")
return config_path
return _create_config
# -- Patched classes -----------------------------------------------------------
[docs]
@pytest.mark.parametrize(
"cls, base",
[
(ColorizedCommand, click.Command),
(ColorizedGroup, click.Group),
],
)
def test_patched_class_inherits_click(cls, base):
assert issubclass(cls, base)
[docs]
@pytest.mark.parametrize("cls", [ColorizedCommand, ColorizedGroup])
def test_patched_class_has_mixin(cls):
assert issubclass(cls, _HelpColorsMixin)
[docs]
@pytest.mark.parametrize("cls", [ColorizedCommand, ColorizedGroup])
def test_patched_class_context(cls):
assert cls.context_class is Context
[docs]
def test_patched_command_no_extra_params():
"""Patched commands carry no default_params."""
cmd = ColorizedCommand(name="test", callback=lambda: None)
option_names = {
opt for p in cmd.params if isinstance(p, click.Option) for opt in p.opts
}
for forbidden in ("--config", "--verbose", "--verbosity", "--timer"):
assert forbidden not in option_names
# -- Target resolution ---------------------------------------------------------
[docs]
@pytest.mark.parametrize(
"script, expected_module, expected_func",
[
("click-extra", "click_extra.__main__", "main"),
("json:tool", "json", "tool"),
("os.path:join", "os.path", "join"),
("json", "json", ""),
],
)
def test_resolve_target(script, expected_module, expected_func):
module_path, function_name = resolve_target(script)
assert module_path == expected_module
assert function_name == expected_func
[docs]
def test_resolve_py_file(tmp_path):
script = tmp_path / "hello.py"
script.write_text("print('hello')")
module_path, function_name = resolve_target(str(script))
assert module_path == str(script)
assert function_name == ""
[docs]
def test_resolve_py_file_missing(tmp_path):
"""A .py path that doesn't exist falls through to module resolution."""
with pytest.raises(click.ClickException, match="Cannot resolve"):
resolve_target(str(tmp_path / "nonexistent.py"))
[docs]
@pytest.mark.parametrize(
"script",
[
"nonexistent_package_xyz_12345",
"no-such-entry-point-xyz",
"",
],
)
def test_resolve_not_found(script):
if not script:
# Empty string: find_spec raises ValueError.
with pytest.raises((click.ClickException, ValueError)):
resolve_target(script)
else:
with pytest.raises(click.ClickException, match="Cannot resolve"):
resolve_target(script)
# -- wrap subcommand -----------------------------------------------------------
[docs]
@pytest.mark.parametrize(
"args, expected",
[
(["--help"], "Run, or introspect, any Click CLI"),
([], "Run, or introspect, any Click CLI"),
],
)
def test_wrap_self(runner, args, expected):
result = runner.invoke(wrap, args)
assert result.exit_code == 0
assert expected in result.output
[docs]
@pytest.mark.parametrize(
"script_fixture, target_args, expected_text",
[
("greet_script", ["--help"], "Greet someone."),
("greet_script", ["--name", "Alice"], "Hello, Alice"),
("custom_cls_script", ["--help"], "Manage recipes and ingredients."),
("custom_cls_script", ["bake", "--help"], "Bake a cake."),
("custom_cls_script", ["bake", "--servings", "8"], "Baking for 8"),
],
)
def test_run_invokes_target(
runner,
script_fixture,
target_args,
expected_text,
request,
):
"""The run subcommand forwards arguments to the target CLI."""
script = request.getfixturevalue(script_fixture)
result = runner.invoke(wrap, [script, *target_args])
assert result.exit_code == 0
assert expected_text in result.output
[docs]
@pytest.mark.parametrize(
"script_fixture, target_args",
[
("greet_script", ["--help"]),
("custom_cls_script", ["--help"]),
("custom_cls_script", ["bake", "--help"]),
],
)
def test_run_colorizes(runner, script_fixture, target_args, request):
"""Help output contains ANSI escape codes."""
script = request.getfixturevalue(script_fixture)
result = runner.invoke(wrap, [script, *target_args], color=True)
assert result.exit_code == 0
assert "\x1b[" in result.output
[docs]
def test_run_highlights_keywords_with_custom_cls(runner, custom_cls_script):
"""Options and subcommands are individually styled, not just headings."""
result = runner.invoke(wrap, [custom_cls_script, "--help"], color=True)
assert result.exit_code == 0
assert "\x1b[36m\x1b[1m--help\x1b[0m" in result.output
assert "\x1b[36m\x1b[1mbake\x1b[0m" in result.output
[docs]
def test_run_unresolvable_target(runner):
result = runner.invoke(wrap, ["nonexistent_xyz_12345"])
assert result.exit_code != 0
assert "Cannot resolve" in result.output
# -- shared external-CLI command resolution -----------------------------------
[docs]
def test_resolve_target_command_returns_command_and_context(greet_script):
"""The shared resolver returns the target's command object and a context."""
cmd, cmd_ctx = resolve_target_command(greet_script)
assert isinstance(cmd, click.Command)
assert isinstance(cmd_ctx, click.Context)
[docs]
def test_resolve_target_command_drills_subcommand(custom_cls_script):
"""Extra subcommands navigate into nested groups."""
cmd, _ = resolve_target_command(custom_cls_script, ("bake",))
assert cmd.name == "bake"
# -- wrap --man: man page generation for an external CLI ----------------------
[docs]
def test_wrap_man_renders_manpage(runner, greet_script):
"""``click-extra wrap --man SCRIPT`` prints the target's roff page and exits."""
result = runner.invoke(demo, ["wrap", "--man", greet_script], color=False)
assert result.exit_code == 0
assert '.TH "' in result.stdout
assert "Greet someone." in result.stdout
assert "Name to greet." in result.stdout
[docs]
def test_wrap_man_custom_class_group(runner, custom_cls_script):
"""``--man`` resolves a custom-class group target via the shared scanner."""
result = runner.invoke(demo, ["wrap", "--man", custom_cls_script], color=False)
assert result.exit_code == 0
assert "Manage recipes and ingredients." in result.stdout
[docs]
def test_wrap_man_drills_into_subcommand(runner, custom_cls_script):
"""Extra arguments after SCRIPT render the nested subcommand's page."""
result = runner.invoke(
demo, ["wrap", "--man", custom_cls_script, "bake"], color=False
)
assert result.exit_code == 0
assert "Bake a cake." in result.stdout
[docs]
def test_wrap_man_unresolvable_target(runner):
result = runner.invoke(demo, ["wrap", "--man", "nonexistent_xyz_12345"])
assert result.exit_code != 0
assert "Cannot resolve" in result.output
[docs]
def test_wrap_man_output_dir_writes_tree(runner, custom_cls_script, tmp_path):
"""``wrap --man --output-dir`` writes one .1 per (sub)command into the dir.
``--output-dir`` must appear before SCRIPT, because wrap runs with
``allow_interspersed_args=False`` so that anything after SCRIPT is
treated as a sub-command path rather than a click-extra flag.
"""
target = tmp_path / "man"
result = runner.invoke(
demo,
["wrap", "--man", "--output-dir", str(target), custom_cls_script],
color=False,
)
assert result.exit_code == 0
names = {path.name for path in target.iterdir()}
assert any(name.endswith(".1") for name in names)
# The root page is named after the resolved script (file-path target uses
# the file stem, which is the temp script's basename).
root_pages = {name for name in names if "-" not in name}
assert root_pages, f"expected a root .1 page among {names!r}"
# Subcommand 'bake' must surface as a hyphenated child page.
assert any(name.endswith("-bake.1") for name in names), names
# Each generated file is a valid roff document.
for path in target.iterdir():
assert path.read_text(encoding="utf-8").startswith('.\\" Generated')
[docs]
def test_wrap_man_output_dir_creates_missing_directory(runner, greet_script, tmp_path):
"""``--output-dir`` creates the target dir when it does not exist yet."""
target = tmp_path / "nested" / "man"
assert not target.exists()
result = runner.invoke(
demo,
["wrap", "--man", "--output-dir", str(target), greet_script],
color=False,
)
assert result.exit_code == 0
assert target.is_dir()
assert list(target.iterdir())
[docs]
def test_wrap_man_output_dir_rejects_subcommand(runner, custom_cls_script, tmp_path):
"""``--output-dir`` always emits the full tree; mixing in a SUBCOMMAND arg
is rejected so the user cannot accidentally produce a tree of pages named
after a partial path."""
target = tmp_path / "man"
result = runner.invoke(
demo,
["wrap", "--man", "--output-dir", str(target), custom_cls_script, "bake"],
color=False,
)
assert result.exit_code != 0
assert "--output-dir" in result.output
assert not target.exists() or not any(target.iterdir())
# -- WrapperGroup default-to-run -----------------------------------------------
[docs]
@pytest.mark.parametrize(
"args, expected",
[
# Unknown name falls through to wrap.
pytest.param(
["--help"],
"Greet someone.",
id="implicit-wrap",
),
# Explicit wrap subcommand.
pytest.param(
["wrap", "--help"],
"Greet someone.",
id="explicit-wrap",
),
# run alias.
pytest.param(
["run", "--help"],
"Greet someone.",
id="run-alias",
),
],
)
def test_group_dispatches_to_wrap(runner, greet_script, args, expected):
"""All invocation forms reach the target CLI."""
full_args = [args[0]]
if args[0] in ("run", "wrap"):
full_args.append(greet_script)
full_args.extend(args[1:])
else:
full_args = [greet_script, *args]
result = runner.invoke(demo, full_args, color=True)
assert result.exit_code == 0
assert expected in result.output
[docs]
@pytest.mark.parametrize(
"group_opts",
[
["--time"],
["--verbosity", "DEBUG"],
["--no-color"],
["--color"],
],
)
def test_group_options_work_with_wrap(runner, greet_script, group_opts):
"""Default Group options are accepted alongside the wrap subcommand."""
result = runner.invoke(
demo,
[*group_opts, "wrap", greet_script, "--help"],
)
assert result.exit_code == 0
assert "Greet someone." in result.output
[docs]
@pytest.mark.parametrize(
"subcommand",
["gradient", "palette", "8color", "colors", "styles"],
)
def test_group_known_subcommands_not_wrapped(runner, subcommand):
"""Known demo subcommands are dispatched directly, not to wrap."""
result = runner.invoke(demo, [subcommand, "--help"])
assert result.exit_code == 0
# -- Config integration --------------------------------------------------------
[docs]
def test_config_verbosity(runner, greet_script, create_config):
"""``verbosity = "DEBUG"`` in pyproject.toml activates debug logging."""
conf = create_config(
"pyproject.toml",
'[tool.click-extra]\nverbosity = "DEBUG"\n',
)
result = runner.invoke(
demo,
["--config", str(conf), "wrap", greet_script, "--help"],
)
assert result.exit_code == 0
assert "Greet someone." in result.output
assert "DEBUG" in (result.output + (result.stderr or ""))
[docs]
def test_config_group_theme(runner, greet_script, create_config):
"""A ``[tool.click-extra]`` ``theme`` key sets the help-screen theme."""
conf = create_config(
"pyproject.toml",
'[tool.click-extra]\ntheme = "light"\n',
)
result = runner.invoke(
demo,
["--config", str(conf), "wrap", greet_script, "--help"],
color=True,
)
assert result.exit_code == 0
assert "Greet someone." in result.output
# -- Config passthrough to target ----------------------------------------------
[docs]
def test_config_target_string(runner, greet_script, create_config):
"""A string config value is forwarded as ``--key value``."""
conf = create_config(
"pyproject.toml",
f'[tool.click-extra.wrap."{Path(greet_script).as_posix()}"]\nname = "Alice"\n',
)
result = runner.invoke(
demo,
["--config", str(conf), "wrap", greet_script],
)
assert result.exit_code == 0
assert "Hello, Alice" in result.output
[docs]
def test_config_target_bool_true(runner, weather_script, create_config):
"""A ``true`` config value is forwarded as ``--flag``."""
conf = create_config(
"pyproject.toml",
f'[tool.click-extra.wrap."{Path(weather_script).as_posix()}"]\nverbose = true\n',
)
result = runner.invoke(
demo,
["--config", str(conf), "wrap", weather_script],
)
assert result.exit_code == 0
assert "(detailed)" in result.output
[docs]
def test_config_target_bool_false_is_noop(runner, weather_script, create_config):
"""A ``false`` config value is skipped: the flag is simply not passed."""
conf = create_config(
"pyproject.toml",
f'[tool.click-extra.wrap."{Path(weather_script).as_posix()}"]\nverbose = false\n',
)
result = runner.invoke(
demo,
["--config", str(conf), "wrap", weather_script],
)
assert result.exit_code == 0
# verbose defaults to false anyway, so output has no "(detailed)".
assert "(detailed)" not in result.output
[docs]
def test_config_target_multiple_keys(runner, weather_script, create_config):
"""Multiple config keys are all forwarded."""
conf = create_config(
"pyproject.toml",
f'[tool.click-extra.wrap."{Path(weather_script).as_posix()}"]\n'
f'city = "Tokyo"\n'
f'unit = "fahrenheit"\n',
)
result = runner.invoke(
demo,
["--config", str(conf), "wrap", weather_script],
)
assert result.exit_code == 0
assert "Tokyo" in result.output
assert "fahrenheit" in result.output
[docs]
def test_config_target_cli_overrides(runner, greet_script, create_config):
"""Explicit CLI args override config target defaults."""
conf = create_config(
"pyproject.toml",
f'[tool.click-extra.wrap."{Path(greet_script).as_posix()}"]\nname = "Alice"\n',
)
result = runner.invoke(
demo,
["--config", str(conf), "wrap", greet_script, "--name", "Bob"],
)
assert result.exit_code == 0
assert "Hello, Bob" in result.output
[docs]
def test_config_target_wrong_section_ignored(
runner,
greet_script,
create_config,
):
"""Config for a different script name has no effect."""
conf = create_config(
"pyproject.toml",
'[tool.click-extra.wrap.other-cli]\nname = "Alice"\n',
)
result = runner.invoke(
demo,
["--config", str(conf), "wrap", greet_script],
)
assert result.exit_code == 0
assert "Hello, World" in result.output
[docs]
def test_config_target_empty_section(runner, greet_script, create_config):
"""An empty target section produces no extra args."""
conf = create_config(
"pyproject.toml",
f'[tool.click-extra.wrap."{Path(greet_script).as_posix()}"]\n',
)
result = runner.invoke(
demo,
["--config", str(conf), "wrap", greet_script],
)
assert result.exit_code == 0
assert "Hello, World" in result.output
[docs]
def test_config_target_no_config(runner, greet_script):
"""No config file at all: target runs with its own defaults."""
result = runner.invoke(
demo,
["--no-config", "wrap", greet_script],
)
assert result.exit_code == 0
assert "Hello, World" in result.output
[docs]
def test_config_target_invalid_option(runner, greet_script, create_config):
"""An invalid config key is caught by the target CLI."""
conf = create_config(
"pyproject.toml",
f'[tool.click-extra.wrap."{Path(greet_script).as_posix()}"]\nnonexistent_option = "bad"\n',
)
result = runner.invoke(
demo,
["--config", str(conf), "wrap", greet_script],
)
assert result.exit_code != 0
assert "No such option" in result.output
# -- _config_args_for_target unit tests ----------------------------------------
def _make_wrap_ctx(full_conf):
"""Create a minimal context chain for _config_args_for_target."""
group_ctx = click.Context(demo, info_name="click-extra")
group_ctx.meta["click_extra.conf_full"] = full_conf
return click.Context(wrap, info_name="wrap", parent=group_ctx)
[docs]
@pytest.mark.parametrize(
"section, script, expected",
[
# String value.
({"name": "Alice"}, "greet", ("--name", "Alice")),
# Boolean true.
({"verbose": True}, "greet", ("--verbose",)),
# Boolean false: skipped (don't pass the flag).
({"verbose": False}, "greet", ()),
# Integer value.
({"count": 3}, "greet", ("--count", "3")),
# List value.
({"tag": ["a", "b"]}, "greet", ("--tag", "a", "--tag", "b")),
# Underscore to dash.
({"dry_run": True}, "greet", ("--dry-run",)),
# Empty section.
({}, "greet", ()),
# Wrong script name.
({"name": "Alice"}, "other", ()),
],
)
def test_config_args_for_target(section, script, expected):
ctx = _make_wrap_ctx({"click-extra": {"wrap": {"greet": section}}})
assert _config_args_for_target(ctx, script) == expected
[docs]
def test_config_args_no_config():
"""No config loaded: returns empty tuple."""
ctx = _make_wrap_ctx({})
assert _config_args_for_target(ctx, "greet") == ()
[docs]
def test_config_args_no_wrap_section():
"""Config exists but has no wrap section."""
ctx = _make_wrap_ctx({"click-extra": {"verbosity": "DEBUG"}})
assert _config_args_for_target(ctx, "greet") == ()