Source code for tests.test_cli_wrapper

# 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

import json
import re
import subprocess
import sys
from pathlib import Path

import click
import pytest

from click_extra.cli import demo
from click_extra.cli_wrapper import (
    WrapperGroup,
    _config_args_for_target,
    _split_navigation,
    patch_click,
    resolve_target,
    resolve_target_command,
    target_prog_name,
    unpatch_click,
    wrap,
)
from click_extra.commands import ColorizedCommand, ColorizedGroup, Context
from click_extra.highlight import _HelpColorsMixin
from click_extra.testing import CliRunner
from click_extra.theme import (
    BUILTIN_THEMES,
    THEME_ENVVAR,
    get_default_theme,
    set_default_theme,
)

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."""

PACKAGE_GROUP_SRC = (
    "import click\n"
    "\n"
    "@click.group()\n"
    "def cli():\n"
    '    """Manage a produce stand."""\n'
    "\n"
    "@cli.command()\n"
    "def restock():\n"
    '    """Restock the shelves."""\n'
    '    click.echo("Restocked")\n'
)
"""Module-level Click group, exposed by a package inside a project directory."""

ORCHARD_SCRIPT = (
    "import click\n"
    "\n"
    '@click.group(context_settings={"auto_envvar_prefix": "ORCHARD"})\n'
    "def orchard():\n"
    '    """Tend the orchard."""\n'
    "\n"
    '@orchard.command(context_settings={"auto_envvar_prefix": "PRESS"})\n'
    '@click.option("--batch", type=int, default=3, help="Apples per batch.")\n'
    "def press(batch):\n"
    '    """Press apples into cider."""\n'
    '    click.echo(f"Pressing {batch} apples")\n'
    "\n"
    'if __name__ == "__main__":\n'
    "    orchard()\n"
)
"""Group whose subcommand sets an environment variable prefix of its own."""


@pytest.fixture(autouse=True)
def _restore_click():
    """Undo any monkey-patching after each test to prevent cross-contamination.

    The process-wide default theme is set back too: ``unpatch_click()`` restores
    only a theme that ``patch_click()`` replaced, not one a test set directly.
    """
    default_theme = get_default_theme()
    yield
    unpatch_click()
    set_default_theme(default_theme)


[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, encoding="UTF-8") 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, encoding="UTF-8") 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, encoding="UTF-8") return str(script)
[docs] @pytest.fixture def make_project(tmp_path): """Build a local project directory exposing a Click group at module level. The returned factory writes packaging metadata (``pyproject.toml`` or ``setup.cfg``) plus a package, in either the flat or src layout. On teardown it undoes the ``sys.path`` and ``sys.modules`` mutations that resolving a project directory triggers, keeping the tests isolated. """ original_path = list(sys.path) original_modules = set(sys.modules) def _make(package, *, layout="flat", metadata="pyproject", scripts=None): project = tmp_path / f"{package}-project" root = project / "src" if layout == "src" else project pkg_dir = root / package pkg_dir.mkdir(parents=True) (pkg_dir / "__init__.py").write_text("", encoding="UTF-8") (pkg_dir / "cli.py").write_text(PACKAGE_GROUP_SRC, encoding="UTF-8") scripts = scripts or {package: f"{package}.cli:cli"} if metadata == "pyproject": lines = [f'[project]\nname = "{package}"\n', "[project.scripts]"] lines += [f'{name} = "{target}"' for name, target in scripts.items()] (project / "pyproject.toml").write_text( "\n".join(lines) + "\n", encoding="UTF-8" ) else: entries = "\n".join( f" {name} = {target}" for name, target in scripts.items() ) (project / "setup.cfg").write_text( f"[options.entry_points]\nconsole_scripts =\n{entries}\n", encoding="UTF-8", ) return project yield _make sys.path[:] = original_path for name in set(sys.modules) - original_modules: del sys.modules[name]
[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')", encoding="UTF-8") 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)
# -- Local project directory resolution ----------------------------------------
[docs] def test_resolve_directory_flat_layout(make_project): """A project directory resolves via its pyproject.toml entry point.""" project = make_project("orchard") assert resolve_target(str(project)) == ("orchard.cli", "cli") # The package root is prepended to sys.path so the import can succeed. assert str(project.resolve()) in sys.path
[docs] def test_resolve_directory_src_layout(make_project): """A src-layout project puts its src/ directory on sys.path.""" project = make_project("vineyard", layout="src") assert resolve_target(str(project)) == ("vineyard.cli", "cli") assert str((project / "src").resolve()) in sys.path
[docs] def test_resolve_directory_setup_cfg(make_project): """console_scripts in setup.cfg resolve when no pyproject.toml is present.""" project = make_project("bakery", metadata="setup_cfg") assert resolve_target(str(project)) == ("bakery.cli", "cli")
[docs] def test_resolve_directory_duplicate_scripts(make_project): """Multiple script names pointing to one target resolve unambiguously.""" project = make_project( "cellar", scripts={"cellar": "cellar.cli:cli", "cl": "cellar.cli:cli"}, ) assert resolve_target(str(project)) == ("cellar.cli", "cli")
[docs] def test_resolve_directory_run(runner, make_project): """The default run mode imports and executes a project directory's CLI.""" project = make_project("pantry") result = runner.invoke(wrap, [str(project), "restock"]) assert result.exit_code == 0 assert "Restocked" in result.output
[docs] def test_resolve_directory_introspect(make_project): """Introspection discovers the command tree of a project directory.""" project = make_project("greenhouse") cmd, _ = resolve_target_command(str(project)) assert isinstance(cmd, click.Group) assert cmd.name == "cli" assert "restock" in cmd.commands
[docs] def test_resolve_directory_no_scripts(tmp_path): """A project without console scripts raises an actionable error.""" (tmp_path / "pyproject.toml").write_text( '[project]\nname = "empty"\n', encoding="UTF-8" ) with pytest.raises(click.ClickException, match="No console-script entry point"): resolve_target(str(tmp_path))
[docs] def test_resolve_directory_multiple_scripts(make_project): """Distinct entry-point targets cannot be disambiguated and raise.""" project = make_project( "market", scripts={"sell": "stall_a.cli:sell", "buy": "stall_b.cli:buy"}, ) with pytest.raises(click.ClickException, match="Multiple console scripts"): resolve_target(str(project))
[docs] def test_resolve_directory_package_not_found(tmp_path): """An entry point whose package is absent on disk raises.""" (tmp_path / "pyproject.toml").write_text( '[project]\nname = "ghost"\n[project.scripts]\nghost = "ghost.cli:main"\n', encoding="UTF-8", ) with pytest.raises(click.ClickException, match="is not under"): resolve_target(str(tmp_path))
# -- 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
[docs] @pytest.mark.parametrize("outer_package", ["sphinx", "click_extra", "", None]) def test_run_usage_line_ignores_the_launcher( runner, make_project, outer_package, monkeypatch ): """The wrapped target's usage line names the script, not click-extra's launcher. A target whose Click command detects its own program name reads `__main__.__package__`, which states how click-extra itself was launched. Left alone, a documentation build running `python -m sphinx` paints a wrapped usage line as `python -m sphinx.flask`. Pinning the detection on the script name keeps it identical whatever the launcher. """ project = make_project("orchard") monkeypatch.setattr( sys.modules["__main__"], "__package__", outer_package, raising=False ) result = runner.invoke(wrap, [str(project), "--help"]) assert result.exit_code == 0 assert f"Usage: {project.name} [OPTIONS]" in result.output assert "python -m" not 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"
[docs] @pytest.mark.parametrize( ("path", "message"), ( (("bake", "glaze"), "'bake' is not a group; cannot navigate to 'glaze'."), (("roast",), "No subcommand 'roast' in 'kitchen'."), ), ) def test_resolve_target_command_rejects_a_wrong_path(custom_cls_script, path, message): with pytest.raises(click.ClickException, match=re.escape(message)): resolve_target_command(custom_cls_script, path)
[docs] @pytest.mark.parametrize( ("source", "expected"), ( pytest.param( "import click\n" "@click.group()\ndef fruits(): pass\n" "@click.command()\ndef pick(): pass\n", "fruits", id="group-before-command", ), pytest.param( "import click\n@click.command()\ndef pick(): pass\n", "pick", id="single-command", ), ), ) def test_module_scan_finds_the_one_command(tmp_path, source, expected): """A module without an entry point is scanned for its one command.""" script = tmp_path / "orchard.py" script.write_text(source, encoding="UTF-8") cmd, _ = resolve_target_command(str(script)) assert cmd.name == expected
[docs] @pytest.mark.parametrize( ("source", "message"), ( pytest.param( "import click\n" "@click.group()\ndef pears(): pass\n" "@click.group()\ndef apples(): pass\n" "@click.command()\ndef pick(): pass\n", r"Multiple command groups in .+: apples, pears\.", id="groups", ), pytest.param( "import click\n" "@click.command()\ndef prune(): pass\n" "@click.command()\ndef pick(): pass\n", r"Multiple commands in .+: pick, prune\.", id="commands", ), pytest.param("BASKETS = 3\n", "No Click command found in", id="none"), ), ) def test_module_scan_refuses_an_ambiguous_module(tmp_path, source, message): script = tmp_path / "orchard.py" script.write_text(source, encoding="UTF-8") with pytest.raises(click.ClickException, match=message): resolve_target_command(str(script))
[docs] def test_params_follow_a_subcommands_own_context_settings(runner, tmp_path): """A subcommand's own `context_settings` shape the context it is described in. Click applies them when it invokes the subcommand, so the environment variable the parameter table names is the one the run reads. """ script = tmp_path / "orchard.py" script.write_text(ORCHARD_SCRIPT, encoding="UTF-8") run = runner.invoke(demo, ["wrap", str(script), "press"], env={"PRESS_BATCH": "7"}) assert run.exit_code == 0 assert "Pressing 7 apples" in run.output _, ctx = resolve_target_command(str(script), ("press",)) assert ctx.auto_envvar_prefix == "PRESS" table = runner.invoke( demo, ["wrap", "--params", "--columns", "id,envvars", "--", str(script), "press"], color=False, ) assert table.exit_code == 0 assert "PRESS_BATCH" in table.output assert "ORCHARD_PRESS_BATCH" not in table.output
[docs] def test_wrapper_group_runs_a_lazy_subcommand(runner, tmp_path): """A lazy subcommand not imported yet runs, rather than being wrapped.""" (tmp_path / "plum_cmd.py").write_text( "from click_extra import command, echo\n" "@command\n" "def plum_cli():\n" " echo('plums = 4')\n", encoding="UTF-8", ) @click.group(cls=WrapperGroup, lazy_subcommands={"plum": "plum_cmd.plum_cli"}) def orchard(): pass orchard.add_command(wrap) sys.path.insert(0, str(tmp_path)) try: result = runner.invoke(orchard, ["plum"], color=False) finally: sys.path.remove(str(tmp_path)) sys.modules.pop("plum_cmd", None) assert result.exit_code == 0 assert result.stdout == "plums = 4\n"
[docs] def test_params_follow_the_targets_own_context_settings(runner, tmp_path): """Every `context_settings` key of the target reaches the parameter table. A target answering to `-h` as well as `--help` lists both spellings, as its own help screen does. """ script = tmp_path / "pantry.py" script.write_text( "import click\n" "@click.command(context_settings={'help_option_names': ['-h', '--help']})\n" "def pantry(): pass\n", encoding="UTF-8", ) table = runner.invoke( demo, ["wrap", "--params", "--columns", "id,spec", "--", str(script)], color=False, ) assert table.exit_code == 0 assert "-h, --help" in table.output
[docs] @pytest.mark.parametrize( ("args", "nav", "rest"), ( ((), (), ()), (("press",), ("press",), ()), (("press", "--batch", "7"), ("press",), ("--batch", "7")), (("--batch", "7"), (), ("--batch", "7")), (("orchard", "press", "-b", "7"), ("orchard", "press"), ("-b", "7")), ), ) def test_split_navigation(args, nav, rest): """Leading words navigate, and everything from the first option is replayed.""" assert _split_navigation(args) == (nav, rest)
# -- wrap --man: man page generation for an external CLI ----------------------
[docs] def test_wrap_man_reads_manual(runner, greet_script): """``click-extra wrap --man -- SCRIPT`` reads the target's manual and exits. A CLI shipping no man page of its own still gets one to read, typeset from its own command tree. """ result = runner.invoke(demo, ["wrap", "--man", greet_script], color=False) assert result.exit_code == 0 assert "Greet someone." in result.stdout assert "Name to greet." in result.stdout
[docs] def test_wrap_help_format_man_emits_source(runner, greet_script): """The roff a packager installs comes from the format, not from --man.""" result = runner.invoke( demo, ["wrap", "--help-format", "man", greet_script], color=False ) assert result.exit_code == 0 assert '.TH "' in result.stdout assert "Greet someone." 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 --help-format man --output-dir`` writes one .1 per (sub)command. ``--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", "--help-format", "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", "--help-format", "man", "--output-dir", str(target), greet_script], color=False, ) assert result.exit_code == 0 assert target.is_dir() assert list(target.iterdir())
[docs] @pytest.mark.parametrize( "destination", ( pytest.param(("--output-dir", "{target}"), id="output-dir"), pytest.param(("--install",), id="install"), ), ) def test_wrap_man_pages_reject_subcommand( runner, custom_cls_script, tmp_path, monkeypatch, destination ): """Writing man pages 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.""" monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) target = tmp_path / "man" result = runner.invoke( demo, [ "wrap", "--help-format", "man", *(arg.format(target=target) for arg in destination), custom_cls_script, "bake", ], color=False, ) assert result.exit_code == 1 assert "cannot be combined with extra SUBCOMMAND arguments" in result.output assert not target.exists() assert not (tmp_path / "data").exists()
[docs] def test_wrap_man_flag_refuses_a_destination(runner, greet_script, tmp_path): """``--man`` reads the manual: the roff to write somewhere is a format's.""" target = tmp_path / "man" result = runner.invoke( demo, ["wrap", "--man", "--output-dir", str(target), greet_script], color=False, ) assert result.exit_code == 2 assert "--output-dir requires --help-format with one of: carapace, man." in ( result.output ) assert not target.exists()
# -- 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( ("theme", "styled_heading"), [ # The styled ``Usage:`` heading uses each theme's 24-bit ``heading`` slot, # which no other built-in emits; re-pin if the palette changes. ("dracula", "\x1b[38;2;255;121;198m\x1b[4mUsage:"), ("nord", "\x1b[38;2;94;129;172m\x1b[4mUsage:"), ], ) def test_wrap_honors_group_theme(runner, greet_script, theme, styled_heading): """A group-level ``--theme`` reaches the wrapped CLI's help screen. ThemeOption records the pick on the shared context meta, but the wrapped target runs under its own fresh context; wrap must bridge the pick to the process default (through patch_click) for the target's help to pick it up. """ result = runner.invoke( demo, ["--theme", theme, "wrap", greet_script, "--help"], color=True, ) assert result.exit_code == 0 assert styled_heading in result.output # Not the 16-color bright-blue heading of the dark default. assert "\x1b[94m\x1b[4mUsage:" not in result.output
[docs] def test_wrap_honors_theme_envvar(runner, greet_script, monkeypatch): """``CLICK_EXTRA_THEME`` reaches a wrapped CLI's help screen too. The group resolves the machine-wide variable like any other source of a palette, and wrap bridges whatever it settled on to the process default the wrapped target renders under. """ monkeypatch.setenv(THEME_ENVVAR, "nord") result = runner.invoke(demo, ["wrap", greet_script, "--help"], color=True) assert result.exit_code == 0 assert BUILTIN_THEMES["nord"].heading("Usage:") in result.output
[docs] @pytest.mark.parametrize("script_fixture", ("greet_script", "custom_cls_script")) @pytest.mark.parametrize( ("color_opt", "colored"), ( ("--color=always", True), ("--color=never", False), ("--no-color", False), ), ) def test_wrap_pins_group_color_on_every_target_kind( runner, request, script_fixture, color_opt, colored ): """The group's tri-state color decision reaches the wrapped target. Both kinds of target must obey it: one built by the patched ``@click.command()`` decorator (so it *is* a ``_HelpColorsMixin``) and one carrying an explicit ``cls=`` (so it stays plain Click). They travel different code paths inside ``patch_click``, so each direction of the flag is checked on both. The runner is left on its own auto-detection here: forcing colors at the stream level would paint the output whatever the flag decided. """ script = request.getfixturevalue(script_fixture) result = runner.invoke(demo, [color_opt, "wrap", script, "--help"]) assert result.exit_code == 0 assert ("\x1b[" in result.output) is colored
[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
[docs] @pytest.mark.once def test_cli_startup_leaves_the_test_tooling_alone(): """Loading the CLI imports neither the test-suite engine nor `click.testing`. Only the `test-suite` command needs them, and `click.testing` drags `pdb` and `asyncio` in behind it: every other command would pay for them at startup. """ tooling = ("click.testing", "click_extra.test_suite", "click_extra.testing", "pdb") probe = ( "import sys\n" "import click_extra.cli\n" f"print(sorted(name for name in {tooling!r} if name in sys.modules))\n" ) process = subprocess.run( (sys.executable, "-c", probe), capture_output=True, text=True, encoding="UTF-8", check=False, ) assert process.returncode == 0, process.stderr assert process.stdout == "[]\n"
[docs] @pytest.mark.parametrize( "patch_themes", ( pytest.param(("nord",), id="one-patch"), pytest.param(("nord", "dracula"), id="two-patches"), ), ) def test_unpatch_click_restores_the_default_theme(patch_themes): """Unpatching hands back the process-wide theme patching replaced. The default in place before the patch is not the built-in ``dark`` one, so a restore to the built-in default fails here. A second patch must not save the theme of the first as the one to restore. """ set_default_theme(BUILTIN_THEMES["solarized-dark"]) for theme_id in patch_themes: patch_click(theme=BUILTIN_THEMES[theme_id]) assert get_default_theme() is BUILTIN_THEMES[theme_id] unpatch_click() assert get_default_theme() is BUILTIN_THEMES["solarized-dark"]
[docs] def test_unpatch_click_keeps_a_default_theme_it_did_not_replace(): """A patch that sets no theme leaves the default theme alone on unpatching. A default set while patched therefore outlives the patch. """ patch_click(theme=None) set_default_theme(BUILTIN_THEMES["solarized-dark"]) unpatch_click() assert get_default_theme() is BUILTIN_THEMES["solarized-dark"]
# -- 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, attached to its flag. ({"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: one flag per item. ({"tag": ["a", "b"]}, "greet", ("--tag=a", "--tag=b")), # Underscore to dash. ({"dry_run": True}, "greet", ("--dry-run",)), # A value starting with a dash stays a value, never a flag of its own. ({"offset": "-5"}, "greet", ("--offset=-5",)), # 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") == ()
[docs] @pytest.mark.parametrize( ("help_format", "expected"), ( ("json", '"name":'), ("markdown", "## Synopsis"), ("man", ".SH SYNOPSIS"), ), ) def test_wrap_help_format_describes_a_foreign_cli( runner, greet_script, help_format, expected ): """A CLI that never heard of Click Extra is rendered from the outside. Same posture as ``--params`` and ``--man``: the target is loaded and walked, never asked to cooperate, so machine-readable help needs no opt-in from the author of the wrapped CLI. """ result = runner.invoke( demo, ["wrap", "--help-format", help_format, greet_script], color=False ) assert result.exit_code == 0 assert expected in result.stdout assert "Greet someone." in result.stdout
[docs] def test_wrap_help_format_json_is_parseable(runner, greet_script): """The JSON rendering of a foreign CLI parses, and carries its options.""" result = runner.invoke( demo, ["wrap", "--help-format", "json", greet_script], color=False ) assert result.exit_code == 0 doc = json.loads(result.stdout) assert doc["short_help"] == "Greet someone." options = [opt for group in doc["option_groups"] for opt in group["options"]] assert any("--name" in opt["names"] for opt in options)
[docs] def test_wrap_help_format_stays_colorless(runner, greet_script): """Machine-readable output carries no ANSI, whatever the group asked for. Every format here is meant to be piped into a parser, which has no use for escape codes: ``--color=always`` paints the help screen, not the export. """ result = runner.invoke( demo, ["--color=always", "wrap", "--help-format", "json", greet_script], color=True, ) assert result.exit_code == 0 assert "\x1b[" not in result.stdout
[docs] @pytest.mark.parametrize( ("help_format", "artifact"), (("carapace", "greet.yaml"), ("man", "greet.1")), ) def test_wrap_install_writes_where_the_consumer_looks( runner, greet_script, tmp_path, monkeypatch, help_format, artifact ): """``--install`` puts each rendering where the tool reading it looks. One destination flag across both formats, each module resolving its own canonical directory: Carapace's spec directory, and the user's man directory. Both honor their XDG variable at call time. """ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) result = runner.invoke( demo, ["wrap", "--help-format", help_format, "--install", greet_script], color=False, ) assert result.exit_code == 0 expected = { "carapace": tmp_path / "config" / "carapace" / "specs" / artifact, "man": tmp_path / "data" / "man" / "man1" / artifact, }[help_format] assert expected.exists() assert str(expected) in result.stdout
[docs] @pytest.mark.parametrize("help_format", ("json", "markdown", "markdown-full")) def test_wrap_destination_refused_for_documents(runner, greet_script, help_format): """A document has nowhere canonical to be installed, and says so.""" result = runner.invoke( demo, ["wrap", "--help-format", help_format, "--install", greet_script], color=False, ) assert result.exit_code == 2 assert "--install requires --help-format with one of: carapace, man." in ( result.output )
[docs] @pytest.mark.parametrize( ("script", "expected"), ( # A path names the binary after its file, never after itself. ("path/to/my_cli.py", "my_cli"), ("./my_cli.py", "my_cli"), ("../my-project", "my-project"), # An import path names a module, so the command's own name stands in. ("flask.cli:cli", "greeter"), ("my_package.cli", "greeter"), # A console script is already what a user types, and outranks the # command's own name when the two differ. ("flask", "flask"), ), ) def test_target_prog_name_is_what_a_user_would_type(script, expected): """Every rendering is titled with the name the target runs under.""" assert target_prog_name(script, click.Command("greeter")) == expected
[docs] def test_every_rendering_agrees_on_the_target_name(runner, tmp_path): """One rule, so the man page, spec, Markdown and tree cannot disagree. The script file is named after neither its command object nor the directory holding it, so a renderer picking the wrong source shows it. """ script = tmp_path / "kettle.py" script.write_text(GREET_SCRIPT, encoding="UTF-8") renders = { fmt: runner.invoke( demo, ["wrap", "--help-format", fmt, str(script)], color=False ) for fmt in ("man", "markdown", "carapace") } assert all(r.exit_code == 0 for r in renders.values()) assert '.TH "KETTLE"' in renders["man"].stdout assert renders["markdown"].stdout.startswith("# kettle\n") assert "\nname: kettle\n" in renders["carapace"].stdout tree = runner.invoke(demo, ["wrap", "--tree", str(script)], color=False) assert tree.exit_code == 0 assert tree.stdout.startswith("kettle ")