# 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 multicall / `argv[0]` dispatch and the invocation-name hook."""
from __future__ import annotations
import copy
import re
from collections.abc import Mapping, Sequence
from enum import Enum
from textwrap import dedent
import click
import pytest
from click_extra import (
argument,
command,
context,
echo,
multicall_group,
option,
pass_context,
)
from click_extra.commands import LazyGroup
from click_extra.multicall import (
MulticallGroup,
PersonalitiesCommand,
normalize_personality,
)
from click_extra.param_tools import make_resilient_context
from click_extra.version import VersionOption
[docs]
def make_kitchen(
personalities: Mapping[str, str | Sequence[str]] | None = None,
**group_kwargs,
):
"""A fresh multicall CLI per test, to keep process-global state from leaking.
The domain is deliberately a kitchen: the test suite asserts dispatch
mechanics, not any real tool's behavior.
"""
@multicall_group(personalities=personalities, **group_kwargs)
def kitchen():
"""A multicall kitchen appliance."""
echo("group callback ran")
@kitchen.command()
@option("--temperature", default="180")
@argument("dishes", nargs=-1)
def bake(temperature, dishes):
"""Bake dishes in the oven."""
echo(f"Baking at {temperature}: {', '.join(dishes) or 'nothing'}")
@kitchen.command(hidden=True)
def defrost():
"""Defrost ingredients."""
echo("defrosting")
@kitchen.command()
@option("--hours", default="2")
@argument("bottles", nargs=-1)
def chill(hours, bottles):
"""Chill bottles in the fridge."""
echo(f"Chilling for {hours} hours: {', '.join(bottles) or 'nothing'}")
return kitchen
[docs]
def test_argv0_dispatch(runner, monkeypatch):
"""With no explicit `prog_name`, the basename of `argv[0]` dispatches."""
kitchen = make_kitchen()
monkeypatch.setattr("sys.argv", ["/usr/local/bin/bake"])
result = runner.invoke(
kitchen, ["--temperature", "230"], prog_name=None, color=False
)
assert result.exit_code == 0
assert "Baking at 230" in result.stdout
[docs]
def test_env_prefix_follows_personality(runner):
"""A personality reads its own `<NAME>_*` envvars, like a standalone binary."""
kitchen = make_kitchen()
result = runner.invoke(
kitchen, [], prog_name="bake", env={"BAKE_TEMPERATURE": "250"}, color=False
)
assert result.exit_code == 0
assert "Baking at 250" in result.stdout
[docs]
def test_explicit_mapping_replaces_identity(runner):
"""An explicit `personalities` mapping is exhaustive, not additive."""
kitchen = make_kitchen(personalities={"quick-chill": ("chill", "--hours", "1")})
result = runner.invoke(kitchen, [], prog_name="quick-chill", color=False)
assert result.exit_code == 0
assert "Chilling for 1 hours" in result.stdout
# `bake` is no longer a personality: the invocation falls through to the
# group, which rejects the bare call with its usage screen.
result = runner.invoke(kitchen, [], prog_name="bake", color=False)
assert result.exit_code == 2
assert "Usage:" in result.output
[docs]
def test_explicit_prog_name_wins(runner, monkeypatch):
"""An explicit `prog_name` takes precedence over `argv[0]`."""
kitchen = make_kitchen()
monkeypatch.setattr("sys.argv", ["/usr/local/bin/chill"])
result = runner.invoke(kitchen, [], prog_name="bake", color=False)
assert result.exit_code == 0
assert "Baking" in result.stdout
[docs]
def test_flat_parse_any_order(runner):
"""Group and subcommand options mix freely in a personality."""
kitchen = make_kitchen()
for args in (
["--verbosity", "INFO", "--temperature", "200", "pie"],
["--temperature", "200", "--verbosity", "INFO", "pie"],
["pie", "--temperature", "200", "--verbosity", "INFO"],
):
result = runner.invoke(kitchen, args, prog_name="bake", color=False)
assert result.exit_code == 0
assert "Baking at 200: pie" in result.stdout
[docs]
def test_group_callback_skipped_in_personality_mode(runner):
"""A personality is standalone: the group's callback does not run."""
kitchen = make_kitchen()
result = runner.invoke(kitchen, [], prog_name="bake", color=False)
assert result.exit_code == 0
assert "group callback ran" not in result.output
result = runner.invoke(kitchen, ["bake"], color=False)
assert result.exit_code == 0
assert "group callback ran" in result.output
[docs]
def test_group_mode_unchanged(runner):
"""A non-matching invocation name leaves the regular group behavior intact."""
kitchen = make_kitchen()
result = runner.invoke(kitchen, ["--help"], color=False)
assert result.exit_code == 0
assert "bake" in result.stdout
assert "chill" in result.stdout
assert "defrost" not in result.stdout
result = runner.invoke(
kitchen, ["bake", "--temperature", "210", "tart"], color=False
)
assert result.exit_code == 0
assert "Baking at 210: tart" in result.stdout
# A name matching no personality falls through to the group as well.
result = runner.invoke(kitchen, ["bake"], prog_name="oven", color=False)
assert result.exit_code == 0
assert "Baking" in result.stdout
[docs]
def test_hidden_subcommand_is_not_a_default_personality(runner):
"""The default identity mapping skips hidden and synthetic subcommands."""
kitchen = make_kitchen()
assert kitchen.match_personality("defrost") is None
assert kitchen.match_personality("help") is None
assert kitchen.match_personality("personalities") is None
assert kitchen.match_personality("bake") == ("bake",)
assert sorted(kitchen.list_personalities()) == ["bake", "chill"]
[docs]
def test_no_envvar_leak_into_group_mode(runner):
"""Building a personality must not rewrite the original params' envvars."""
kitchen = make_kitchen(personalities={"quick-bake": ("bake", "--temperature", "1")})
result = runner.invoke(kitchen, [], prog_name="quick-bake", color=False)
assert result.exit_code == 0
# The group's options keep their `KITCHEN_*` auto envvars.
verbosity = next(
p for p in kitchen.params if "--verbosity" in getattr(p, "opts", [])
)
assert verbosity.envvar is not None
assert all(var.startswith("KITCHEN_") for var in verbosity.envvar)
# The subcommand's options keep their own `BAKE_*` auto envvars.
temperature = next(
p
for p in kitchen.commands["bake"].params
if "--temperature" in getattr(p, "opts", [])
)
assert temperature.envvar is not None
assert all(var.startswith("BAKE_") for var in temperature.envvar)
[docs]
@pytest.mark.parametrize(
("argv0", "expected"),
[
("/usr/local/bin/bake", "bake"),
("/usr/local/bin/bake.exe", "bake"),
("/usr/local/bin/BAKE.EXE", "BAKE"),
("bake", "bake"),
("", None),
],
)
def test_resolve_invocation_name(runner, monkeypatch, argv0, expected):
"""The detector uses the unresolved basename and strips `.exe`."""
kitchen = make_kitchen()
monkeypatch.setattr("sys.argv", [argv0])
assert kitchen.resolve_invocation_name() == expected
# An explicit name always wins.
assert kitchen.resolve_invocation_name("chill") == "chill"
[docs]
def test_unknown_subcommand_in_mapping(runner):
"""A personality mapping an absent subcommand fails loudly at dispatch."""
kitchen = make_kitchen(personalities={"ghost": "missing"})
result = runner.invoke(kitchen, [], prog_name="ghost", color=False)
assert result.exit_code != 0
assert isinstance(result.exception, RuntimeError)
[docs]
def test_version_names_each_personality(runner):
"""Each personality's `--version` names itself, in sequence.
Regression test: `VersionOption` used to cache `prog_name` on the
instance, pinning the first name seen in a process forever. The warm-up
invocation with debug logging matters: it resolves every version field
without anyone calling `--version` at all.
"""
kitchen = make_kitchen()
result = runner.invoke(kitchen, ["--version"], prog_name="bake", color=False)
assert result.exit_code == 0
assert result.output.startswith("bake, version ")
result = runner.invoke(
kitchen, ["--verbosity", "DEBUG"], prog_name="bake", color=False
)
assert result.exit_code == 0
result = runner.invoke(kitchen, ["--version"], prog_name="chill", color=False)
assert result.exit_code == 0
assert result.output.startswith("chill, version ")
[docs]
def test_version_prog_name_varies_without_multicall(runner):
"""`--version` follows an explicit `prog_name` even in plain group mode."""
kitchen = make_kitchen()
result = runner.invoke(kitchen, ["--version"], prog_name="kitchen", color=False)
assert result.exit_code == 0
assert result.output.startswith("kitchen, version ")
result = runner.invoke(kitchen, ["--version"], prog_name="toaster", color=False)
assert result.exit_code == 0
assert result.output.startswith("toaster, version ")
[docs]
def test_personalities_subcommand(runner):
"""The auto-injected `personalities` subcommand lists the names."""
kitchen = make_kitchen(personalities={"quick-chill": ("chill", "--hours", "1")})
result = runner.invoke(kitchen, ["personalities"], color=False)
assert result.exit_code == 0
assert "quick-chill" in result.stdout
assert "chill --hours 1" in result.stdout
# The group's help lists it as a regular subcommand.
result = runner.invoke(kitchen, ["--help"], color=False)
assert "personalities" in result.stdout
[docs]
def test_personalities_command_can_be_disabled(runner):
"""`personalities_command=False` suppresses the listing subcommand."""
kitchen = make_kitchen(personalities_command=False)
assert "personalities" not in kitchen.commands
result = runner.invoke(kitchen, ["--help"], color=False)
assert "personalities" not in result.stdout
[docs]
def test_personalities_command_can_be_overridden(runner):
"""A `personalities` subcommand of the author's replaces the injected one."""
kitchen = make_kitchen()
@kitchen.command(name="personalities")
def recipes():
"""List the recipes."""
echo("pie, tart")
assert not isinstance(kitchen.commands["personalities"], PersonalitiesCommand)
result = runner.invoke(kitchen, ["personalities"], color=False)
assert result.exit_code == 0
assert result.stdout == "group callback ran\npie, tart\n"
result = runner.invoke(kitchen, ["--help"], color=False)
assert result.stdout.count("personalities") == 1
assert "List the recipes." in result.stdout
[docs]
def test_injected_subcommands_listed_last_in_declaration_order():
"""`help` and `personalities` trail the author's own subcommands."""
kitchen = make_kitchen(sort_subcommands=False)
ctx = make_resilient_context(kitchen, kitchen.name)
assert kitchen.list_commands(ctx) == [
"bake",
"defrost",
"chill",
"help",
"personalities",
]
[docs]
def test_plain_click_subcommand_is_a_personality(runner):
"""A subcommand built by Click alone dispatches like any other."""
kitchen = make_kitchen()
@click.command()
@click.option("--minutes", default="3")
def boil(minutes):
"""Boil water."""
echo(f"Boiling for {minutes} minutes.")
kitchen.add_command(boil)
assert kitchen.match_personality("boil") == ("boil",)
result = runner.invoke(kitchen, ["--minutes", "5"], prog_name="boil", color=False)
assert result.exit_code == 0
assert result.stdout == "Boiling for 5 minutes.\n"
[docs]
def test_normalize_personality():
"""Personality values normalize to non-empty token tuples."""
assert normalize_personality("chill") == ("chill",)
assert normalize_personality(("chill", "--hours", "1")) == ("chill", "--hours", "1")
bad: Sequence[str]
for bad in ((), [], ("",), ("chill", "")):
with pytest.raises(ValueError):
normalize_personality(bad)
with pytest.raises(TypeError):
normalize_personality(42)
[docs]
def test_deepcopy_keeps_every_sentinel_identical():
"""A copied parameter carries its enum members over, it does not rebuild them.
An enum member is a singleton, so a copy handing back an equal-but-distinct
one is already wrong, and the `object()` behind a
[PEP 661](https://peps.python.org/pep-0661/) sentinel never survives a
rebuild. Walking the whole default option set catches the next parameter to
grow one.
"""
kitchen = make_kitchen()
clones = copy.deepcopy(kitchen.params)
assert len(clones) == len(kitchen.params)
# Pair by position, not by name: `--config` and `--no-config` share the
# `config` destination, so a name lookup matches the wrong parameter. Read
# the stored attribute rather than the resolved one, since `ConfigOption`
# answers `default` with a bound method computing the pattern.
guarded = 0
for source, clone in zip(kitchen.params, clones, strict=True):
for attribute, value in vars(source).items():
if not isinstance(value, Enum):
continue
guarded += 1
assert vars(clone)[attribute] is value, (
f"{source.name}.{attribute} was rebuilt instead of carried over."
)
assert guarded, "No enum-valued parameter attribute left to guard."
[docs]
def test_version_option_deepcopy_drops_cache():
"""Deepcopying a warmed-up `VersionOption` yields fresh field resolution."""
kitchen = make_kitchen()
version_option = next(p for p in kitchen.params if isinstance(p, VersionOption))
# Warm the cache with a process-constant field.
_ = version_option.package_version
clone = copy.deepcopy(version_option)
assert clone is not version_option
assert "package_version" not in clone.__dict__
# Configuration state survives the copy.
assert clone.message == version_option.message
[docs]
def test_lazy_subcommand_is_a_personality(runner, tmp_path, monkeypatch):
"""A lazy multicall group answers to a subcommand it has not imported yet.
Personality dispatch runs before any context exists, so the name is resolved
through the lazy registry rather than the subcommands already loaded. Invoked
as `bake`, the group imports the subcommand and runs it, instead of falling
through to its own usage screen.
"""
(tmp_path / "oven_cmds.py").write_text(
dedent(
'''
from click_extra import command, echo
@command()
def bake():
"""Bake dishes in the oven."""
echo("baking")
'''
),
encoding="UTF-8",
)
monkeypatch.syspath_prepend(str(tmp_path))
class LazyKitchen(MulticallGroup, LazyGroup): # type: ignore[misc]
"""A multicall group importing its subcommands on demand."""
kitchen = LazyKitchen(name="kitchen", lazy_subcommands={"bake": "oven_cmds.bake"})
assert kitchen.match_personality("bake") == ("bake",)
assert kitchen.list_personalities() == {"bake": ("bake",)}
result = runner.invoke(kitchen, [], prog_name="bake", color=False)
assert result.exit_code == 0
assert result.stdout == "baking\n"