Source code for tests.test_carapace

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

from __future__ import annotations

import json
import os
import platform
import stat
import subprocess
import sys
import tarfile
from enum import Enum
from pathlib import Path

import click
import cloup
import pytest
import yaml
from click.shell_completion import CompletionItem, get_completion_class
from cloup.constraints import mutually_exclusive

# jsonschema only gates the Carapace completion-spec validation tests; skip the
# whole module instead of failing collection when it is absent (hermetic
# builders such as Guix and Nixpkgs do not ship it by default).
jsonschema = pytest.importorskip("jsonschema")

import click_extra
from click_extra.carapace import (
    CARAPACE_DOCS_URL,
    CarapaceComplete,
    _flag_key,
    _flag_name,
    dump_carapace_spec,
    get_cache_dir,
    install_carapace_spec,
    to_carapace_spec,
    xdg_dir,
)
from click_extra.cli import demo
from click_extra.testing import CliRunner

from .conftest import fetch_or_skip

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterator

CARAPACE_SCHEMA = json.loads(
    (Path(__file__).parent / "carapace-spec.schema.json").read_text(encoding="UTF-8")
)


# -- Sample CLIs (domain-neutral) ---------------------------------------------


[docs] class FruitType(click.ParamType): """A param type with a custom, dynamic ``shell_complete`` override.""" name = "fruit"
[docs] def shell_complete(self, ctx, param, incomplete): return [CompletionItem("apple"), CompletionItem("banana")]
[docs] def complete_city(ctx, param, incomplete): """A param-level ``shell_complete`` callback.""" return [CompletionItem("paris", help="France"), CompletionItem("oslo")]
@cloup.group() def weather(): """Weather tools.""" @weather.command(aliases=["fc"]) @click.option("-v", "--verbose", count=True, help="Increase `verbosity`.") @click.option("--include", multiple=True, help="Patterns to include.") @click.option("--unit", type=click.Choice(["celsius", "fahrenheit"]), help="Scale.") @click.option("--out", type=click.Path(dir_okay=False), help="Output file.") @click.option("--into", type=click.Path(file_okay=False, dir_okay=True)) @click.option("--loud/--quiet", "loud", help="Loud mode.") @click.option("--fruit", type=FruitType(), help="Dynamic fruit.") @click.option("--city", shell_complete=complete_city, help="Dynamic city.") @click.argument("season", type=click.Choice(["spring", "summer"])) @click.argument("days", nargs=-1, type=click.Path()) def forecast(verbose, include, unit, out, into, loud, fruit, city, season, days): """Forecast for a SEASON.""" @weather.command(hidden=True) def secret(): """A hidden command.""" @cloup.command() @cloup.option_group( "Sources", cloup.option("--local"), cloup.option("--remote"), constraint=mutually_exclusive, ) def sync(local, remote): """Sync from a source.""" @click_extra.group() def xtool(): """A Click Extra group with the full default option set.""" @xtool.command() @click_extra.option("--name", help="A name.") def hello(name): """Greet someone.""" WEATHER_SPEC = to_carapace_spec(weather, prog_name="weather") FORECAST = next(c for c in WEATHER_SPEC["commands"] if c["name"] == "forecast") SYNC_SPEC = to_carapace_spec(sync, prog_name="sync") XTOOL_SPEC = to_carapace_spec(xtool, prog_name="xtool") # -- Flag-key grammar ---------------------------------------------------------
[docs] @pytest.mark.parametrize( ("opts", "value", "optarg", "repeatable", "expected"), [ (["--foo"], False, False, False, "--foo"), (["--foo"], True, False, False, "--foo="), (["--foo"], True, True, False, "--foo?"), (["--foo"], False, False, True, "--foo*"), (["--foo"], True, False, True, "--foo=*"), (["-f", "--foo"], False, False, False, "-f, --foo"), (["-f", "--foo"], True, False, False, "-f, --foo="), (["-f"], False, False, True, "-f*"), (["--verbose", "-v"], False, False, True, "-v, --verbose*"), ], ) def test_flag_key_grammar(opts, value, optarg, repeatable, expected): assert ( _flag_key(opts, value=value, optarg=optarg, repeatable=repeatable) == expected )
[docs] @pytest.mark.parametrize( ("opts", "value", "repeatable", "expected"), [ (["--foo"], False, False, "--foo&"), (["--foo"], True, False, "--foo=&"), (["--foo"], True, True, "--foo=*&"), ], ) def test_hidden_flag_key_carries_the_modifier(opts, value, repeatable, expected): """A hidden flag is exported marked, never silently. Carapace completes what a spec lists, so a hidden option stays reachable like a hidden command does; `&` is what keeps a reader able to tell. """ assert _flag_key(opts, value=value, repeatable=repeatable, hidden=True) == expected
[docs] @pytest.mark.parametrize( ("opts", "value", "repeatable", "expected"), [ (["--foo"], False, False, "--foo!"), (["--foo"], True, False, "--foo=!"), (["--foo"], False, True, "--foo*!"), ], ) def test_required_flag_key_carries_the_modifier(opts, value, repeatable, expected): assert ( _flag_key(opts, value=value, repeatable=repeatable, required=True) == expected )
[docs] def test_required_and_hidden_modifiers_keep_upstream_order(): """`!` before `&`, the order `carapace-spec` writes them in.""" assert ( _flag_key(["--foo"], value=True, repeatable=True, required=True, hidden=True) == "--foo=*!&" )
[docs] def test_required_option_is_marked(): @click_extra.command @click_extra.option("--city", required=True, help="City to forecast.") @click_extra.option("--maybe", help="Optional.") def forecast(city, maybe): """Show the weather forecast.""" spec = to_carapace_spec(forecast, prog_name="forecast") assert spec["flags"]["--city=!"] == "City to forecast." assert spec["flags"]["--maybe="] == "Optional." jsonschema.validate(spec, CARAPACE_SCHEMA)
[docs] def test_required_boolean_pair_is_left_unmarked(): """Either spelling satisfies Click, and the spec cannot say "one of these". Marking both would demand both; marking the positive alone would hide that the negative answers too. """ @click_extra.command @click_extra.option("--ascii/--no-ascii", required=True, help="Toggle art.") def forecast(ascii): """Show the weather forecast.""" flags = set(to_carapace_spec(forecast, prog_name="forecast")["flags"]) assert {"--ascii", "--no-ascii"} <= flags assert not {"--ascii!", "--no-ascii!"} & flags
[docs] def test_hidden_option_is_exported_and_marked(): @click_extra.command @click_extra.option("--secret", hidden=True, help="Never shown.") def pick(secret): """Pick the ripe fruit.""" spec = to_carapace_spec(pick, prog_name="pick") assert spec["flags"]["--secret=&"] == "Never shown." jsonschema.validate(spec, CARAPACE_SCHEMA)
[docs] @pytest.mark.parametrize( ("opts", "expected"), [ (["--table-format"], "table-format"), (["-v", "--verbose"], "verbose"), (["-q"], "q"), ], ) def test_flag_name(opts, expected): assert _flag_name(opts) == expected
# -- Static spec encodings ----------------------------------------------------
[docs] def test_root_description(): assert WEATHER_SPEC["name"] == "weather" assert WEATHER_SPEC["description"] == "Weather tools."
[docs] def test_description_drops_the_no_rewrap_marker(): """Click's ``\b`` marker never reaches the YAML. It is a control character telling Click's own formatter not to rewrap, not prose: a description carrying it would hand Carapace a stray byte. """ @click.command() @click.option("--layout", help="\b\nrows: top to bottom\ncolumns: left to right") def table(layout): """Draw a table.""" spec = to_carapace_spec(table, prog_name="table") assert spec["flags"]["--layout="] == "rows: top to bottom columns: left to right"
[docs] def test_command_description_loses_its_backticks_like_a_flag_does(): """A command's description and its flags share one normalizer. A reST inline literal reads the same in both: its text is kept, and the backticks that only HTML docs render are dropped. """ @click.command() @click.option("--bed", help="Plan a ``raised`` bed.") def garden(bed): """Plan a ``garden`` bed.""" spec = to_carapace_spec(garden, prog_name="garden") assert spec["description"] == "Plan a garden bed." assert spec["flags"]["--bed="] == "Plan a raised bed."
[docs] def test_count_flag_is_repeatable(): assert "-v, --verbose*" in FORECAST["flags"]
[docs] def test_operand_help_reaches_the_documentation_block(): """An operand's help has one home in the spec, and it is not `completion`. `completion.positional` holds actions, so the prose rides the sibling `documentation` block, indexed the same way. """ @click_extra.command @click_extra.argument("city", help="City to forecast.") @click_extra.argument("files", nargs=-1, help="Files to read.") def forecast(city, files): """Show the weather forecast.""" spec = to_carapace_spec(forecast, prog_name="forecast") assert spec["documentation"] == { "positional": ["City to forecast."], "positionalany": "Files to read.", } jsonschema.validate(spec, CARAPACE_SCHEMA)
[docs] def test_undocumented_operand_leaves_no_documentation_block(): """Nothing to say means no block at all, rather than a row of blanks.""" @click_extra.command @click_extra.argument("city") def forecast(city): """Show the weather forecast.""" assert "documentation" not in to_carapace_spec(forecast, prog_name="forecast")
[docs] def test_documentation_stays_aligned_with_its_actions(): """A documented operand behind an undocumented one keeps its index. The two lists are read positionally, so a dropped blank would shift the prose onto the wrong operand. """ @click_extra.command @click_extra.argument("crate") @click_extra.argument("city", help="City to forecast.") def forecast(crate, city): """Show the weather forecast.""" spec = to_carapace_spec(forecast, prog_name="forecast") assert spec["documentation"]["positional"] == ["", "City to forecast."]
[docs] def test_option_computing_its_help_still_describes_itself(): """An option leaving `help` at `None` still reaches the spec described. Click Extra's own `-v` and `-q` build their text from the resolved base verbosity, so reading the attribute hands back nothing. """ @click_extra.command def orchard(): """Tend an orchard.""" flags = to_carapace_spec(orchard, prog_name="orchard")["flags"] assert flags["-v, --verbose*"].startswith("Increase the default") assert flags["-q, --quiet*"].startswith("Decrease the default")
[docs] def test_multiple_value_flag(): assert "--include=*" in FORECAST["flags"]
[docs] def test_boolean_pair_splits_into_two_flags(): assert "--loud" in FORECAST["flags"] assert "--quiet" in FORECAST["flags"] # Neither half takes a value. assert "--loud=" not in FORECAST["flags"]
[docs] def test_choice_inlined(): assert FORECAST["completion"]["flag"]["unit"] == ["celsius", "fahrenheit"]
[docs] def test_enum_choice_completes_by_member_name(): """An ``Enum`` choice completes by the member names Click accepts. ``str()`` of a member reads ``Fruit.APPLE``, which Click rejects as a value: the spec has to carry the spelling the shell can type. """ class Fruit(Enum): APPLE = "apple" BANANA = "banana" @click.command() @click.option("--fruit", type=click.Choice(Fruit)) @click.argument("side", type=click.Choice(Fruit)) def pick(fruit, side): """Pick a fruit.""" spec = to_carapace_spec(pick, prog_name="pick") assert spec["completion"]["flag"]["fruit"] == ["APPLE", "BANANA"] assert spec["completion"]["positional"] == [["APPLE", "BANANA"]]
[docs] def test_path_file_vs_directory(): assert FORECAST["completion"]["flag"]["out"] == ["$files"] assert FORECAST["completion"]["flag"]["into"] == ["$directories"]
[docs] def test_positional_choice(): # season is the sole fixed-arity argument; its choices land in positional[0]. assert FORECAST["completion"]["positional"] == [["spring", "summer"]]
[docs] def test_positionalany_variadic_path(): assert FORECAST["completion"]["positionalany"] == ["$files"]
[docs] def test_hidden_command(): secret_spec = next(c for c in WEATHER_SPEC["commands"] if c["name"] == "secret") assert secret_spec["hidden"] is True
[docs] def test_aliases(): assert FORECAST["aliases"] == ["fc"]
[docs] def test_exclusive_flags_from_option_group(): assert SYNC_SPEC["exclusiveflags"] == [["local", "remote"]]
# -- Dynamic completion actions -----------------------------------------------
[docs] @pytest.mark.parametrize("flag_name", ["fruit", "city"]) def test_dynamic_action_emitted(flag_name): action = FORECAST["completion"]["flag"][flag_name] assert len(action) == 1 macro = action[0] assert macro.startswith("$(") assert "_WEATHER_COMPLETE=carapace_complete" in macro # The macro bakes the full command path (root + subcommand) AND the option # spelling. Carapace forwards neither to the macro: it strips parent command # names from the words, and routes a flag's value completion to this per-flag # action without putting the flag in those words. Both are restored so Click # resolves the right option's value under the right subcommand. assert f"COMP_WORDS=weather forecast $* --{flag_name}" in macro
[docs] def test_completion_variable_follows_clicks_derivation(): """The callback exports the variable Click reads for the program name. Click turns both ``-`` and ``.`` into ``_`` before upper-casing, so a script run as ``garden.py`` listens to ``_GARDEN_PY_COMPLETE``. Any other spelling is a variable nothing reads, and the callback silently completes nothing. """ @click.command() @click.option("--city", shell_complete=complete_city) def garden(city): """Tend a garden.""" spec = to_carapace_spec(garden, prog_name="garden.py") (action,) = spec["completion"]["flag"]["city"] assert "_GARDEN_PY_COMPLETE=carapace_complete" in action assert "GARDEN.PY" not in action
# -- Flag scope --------------------------------------------------------------- def _spec_nodes(node: dict) -> Iterator[dict]: """Every command node of a spec, the root first.""" yield node for sub in node.get("commands", ()): yield from _spec_nodes(sub)
[docs] @pytest.mark.parametrize( "spec", [WEATHER_SPEC, SYNC_SPEC, XTOOL_SPEC], ids=["weather", "sync", "xtool"], ) def test_no_flag_is_persistent(spec): """Click parses an option only at the level of its own command. A `persistentflags` entry would offer an option after a subcommand, where Click rejects it, so no node of the spec carries one. """ for node in _spec_nodes(spec): assert "persistentflags" not in node, node["name"]
[docs] def test_root_default_options_stay_on_the_root(): flags = XTOOL_SPEC["flags"] # A sampling across value, optional-value, count and boolean shapes. assert "--version" in flags assert "--config=" in flags assert "--color?" in flags assert "-v, --verbose*" in flags
[docs] def test_subcommand_lists_only_its_own_options(): hello_spec = next(c for c in XTOOL_SPEC["commands"] if c["name"] == "hello") assert hello_spec["flags"] == { "--name=": "A name.", "-h, --help": "Show this message and exit.", }
[docs] def test_spec_agrees_with_click_on_flag_scope(invoke): """The spec offers a root option after a subcommand exactly when Click takes it.""" hello_spec = next(c for c in XTOOL_SPEC["commands"] if c["name"] == "hello") # What Carapace completes after `hello`: its own flags plus the root's inherited # ones. offered = {*hello_spec["flags"], *XTOOL_SPEC.get("persistentflags", {})} assert "--version" not in offered result = invoke(xtool, "hello", "--version") assert result.exit_code == 2 assert "No such option '--version'" in result.stderr
[docs] def test_plain_cli_keeps_own_lookalike_option(): # weather is a plain cloup group: its only default is --help, so forecast's # own -v/--verbose must survive rather than being mistaken for the default. assert "-v, --verbose*" in FORECAST["flags"] assert WEATHER_SPEC["flags"] == {"--help": "Show this message and exit."}
# -- Schema conformance -------------------------------------------------------
[docs] @pytest.mark.parametrize( "spec", [WEATHER_SPEC, SYNC_SPEC, XTOOL_SPEC], ids=["weather", "sync", "xtool"], ) def test_spec_validates_against_schema(spec): jsonschema.validate(spec, CARAPACE_SCHEMA)
# -- YAML serialization -------------------------------------------------------
[docs] def test_dump_has_header_and_name(): out = dump_carapace_spec(weather, prog_name="weather") assert out.startswith("# Generated by Click Extra") assert f"# Documentation: {CARAPACE_DOCS_URL}\n" in out # Without an explicit invocation, the programmatic path omits the "# $" line. assert "\n# $ " not in out assert "\nname: weather\n" in out
[docs] def test_dump_records_invocation(): out = dump_carapace_spec( weather, prog_name="weather", invocation="my-cli --carapace" ) assert "# $ my-cli --carapace\n" in out
[docs] def test_dump_requires_pyyaml(monkeypatch): """A missing PyYAML surfaces as a pointed error at dump time, not at import.""" # A None entry makes `import yaml` raise ImportError, as a missing package does. monkeypatch.setitem(sys.modules, "yaml", None) with pytest.raises(ImportError, match=r"click-extra\[carapace\]"): dump_carapace_spec(weather, prog_name="weather")
[docs] def test_help_format_carapace_without_pyyaml(monkeypatch): """`--help-format carapace` names the extra to install, with no traceback.""" monkeypatch.setitem(sys.modules, "yaml", None) @click_extra.command def orchard(): """Pick apples.""" result = CliRunner().invoke(orchard, ["--help-format", "carapace"], color=False) assert result.exit_code == 1 assert not result.stdout assert result.stderr == ( "Error: Carapace spec serialization requires an optional dependency. " "Install it with: uv pip install 'click-extra[carapace]'\n" )
# -- Dynamic completion backend (CarapaceComplete) ----------------------------
[docs] @pytest.mark.once def test_carapace_complete_registered(): assert get_completion_class("carapace") is CarapaceComplete
[docs] @pytest.mark.parametrize( ("value", "help_text", "expected"), [ ("apple", None, "apple"), ("apple", "a fruit", "apple\ta fruit"), ], ) def test_format_completion(value, help_text, expected): comp = CarapaceComplete(weather, {}, "weather", "_WEATHER_COMPLETE") assert comp.format_completion(CompletionItem(value, help=help_text)) == expected
[docs] def test_get_completion_args_reads_comp_words(monkeypatch): monkeypatch.setenv("COMP_WORDS", "weather forecast --unit") comp = CarapaceComplete(weather, {}, "weather", "_WEATHER_COMPLETE") args, incomplete = comp.get_completion_args() assert args == ["forecast", "--unit"] assert incomplete == ""
[docs] def test_complete_reuses_click_machinery(): # The backend resolves the same completions Click would for a custom callback. comp = CarapaceComplete(weather, {}, "weather", "_WEATHER_COMPLETE") items = comp.get_completions(["forecast", "--city"], "") values = {item.value for item in items} assert values == {"paris", "oslo"}
[docs] def test_subcommand_dynamic_completion_resolves_end_to_end(monkeypatch): """The COMP_WORDS a subcommand flag's generated macro builds must let the backend resolve that option's value, not the root command. When completing a subcommand flag's value, Carapace forwards an empty word list to the macro: it strips the parent command names, and keeps the flag being completed in its own state rather than passing it through. So the macro bakes both the command path and the flag spelling, and ``$*`` expands to nothing. This reconstructs the resulting COMP_WORDS from the generated macro and confirms the backend resolves the forecast callback. A root-only, flag-less macro would reconstruct bare ``weather`` and complete the subcommand list instead. """ macro = FORECAST["completion"]["flag"]["city"][0] # The macro's COMP_WORDS template, with $* expanded to the empty word list # Carapace passes for a flag value. template = macro.split("COMP_WORDS=", 1)[1].split('"', 1)[0] monkeypatch.setenv("COMP_WORDS", template.replace("$*", "")) comp = CarapaceComplete(weather, {}, "weather", "_WEATHER_COMPLETE") args, incomplete = comp.get_completion_args() assert args == ["forecast", "--city"] values = {item.value for item in comp.get_completions(args, incomplete)} assert values == {"paris", "oslo"}
# -- CLI surface (wrap --help-format carapace) -------------------------------------------- GREET_SCRIPT = """ import click @click.group() def cli(): "Greeter." @cli.command() @click.option("--lang", type=click.Choice(["en", "fr"])) @click.argument("name") def hi(lang, name): "Say hi to NAME." """
[docs] @pytest.fixture def runner(): return CliRunner()
[docs] @pytest.fixture def greet_script(tmp_path): script = tmp_path / "greet.py" script.write_text(GREET_SCRIPT, encoding="UTF-8") return str(script)
[docs] def test_wrap_carapace_emits_valid_spec(runner, greet_script): result = runner.invoke( demo, ["wrap", "--help-format", "carapace", greet_script], color=False ) assert result.exit_code == 0 assert result.stdout.startswith("# Generated by Click Extra") # The header records the reconstructed wrap command and the docs link. assert "\n# $ click-extra wrap --help-format carapace -- " in result.stdout assert f"# Documentation: {CARAPACE_DOCS_URL}\n" in result.stdout # The emitted YAML round-trips and conforms to the schema. spec = yaml.safe_load(result.stdout) jsonschema.validate(spec, CARAPACE_SCHEMA) assert any(c["name"] == "hi" for c in spec["commands"])
[docs] def test_wrap_carapace_install(runner, greet_script, tmp_path, monkeypatch): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) result = runner.invoke( demo, ["wrap", "--help-format", "carapace", "--install", greet_script], color=False, ) assert result.exit_code == 0 written = tmp_path / "carapace" / "specs" / "greet.yaml" assert written.exists() assert str(written) in result.stdout
[docs] @pytest.mark.parametrize( ("environ", "expected"), ( pytest.param( {"XDG_CONFIG_HOME": "{tmp}/xdg"}, "{tmp}/xdg/carapace/specs", id="set" ), pytest.param( {"XDG_CONFIG_HOME": ""}, "{tmp}/.config/carapace/specs", id="empty" ), pytest.param({}, "{tmp}/.config/carapace/specs", id="unset"), ), ) def test_xdg_dir_resolves_at_call_time(tmp_path, monkeypatch, environ, expected): """The variable wins when set and non-empty, else the default under ``HOME``.""" monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) for name, value in environ.items(): monkeypatch.setenv(name, value.format(tmp=tmp_path)) resolved = xdg_dir( "XDG_CONFIG_HOME", "~/.config/carapace/specs", "carapace", "specs" ) assert resolved == Path(expected.format(tmp=tmp_path))
[docs] @pytest.mark.parametrize( ("platform_id", "environ", "expected"), ( pytest.param("macos", {}, "/home/fig/Library/Caches/plum", id="macos"), pytest.param( "macos", {"XDG_CACHE_HOME": "/orchard/cache"}, "/orchard/cache/plum", id="macos-xdg", ), pytest.param("linux", {}, "/home/fig/.cache/plum", id="linux"), pytest.param( "linux", {"XDG_CACHE_HOME": " /orchard/cache/ "}, "/orchard/cache/plum", id="linux-xdg-padded", ), # The XDG specification says to ignore a relative path. pytest.param( "macos", {"XDG_CACHE_HOME": "orchard/cache"}, "/home/fig/Library/Caches/plum", id="macos-xdg-relative", ), pytest.param( "linux", {"XDG_CACHE_HOME": "~/cache"}, "/home/fig/.cache/plum", id="linux-xdg-tilde", ), pytest.param( "linux", {"XDG_CACHE_HOME": " "}, "/home/fig/.cache/plum", id="linux-xdg-blank", ), pytest.param( "windows", {"LOCALAPPDATA": "/orchard/local", "XDG_CACHE_HOME": "/orchard/cache"}, "/orchard/local/plum/Cache", id="windows", ), pytest.param( "windows", {}, "/home/fig/AppData/Local/plum/Cache", id="windows-no-localappdata", ), ), ) def test_get_cache_dir(monkeypatch, platform_id, environ, expected): """An absolute `XDG_CACHE_HOME` comes first, except on Windows.""" monkeypatch.setattr("extra_platforms.is_macos", lambda: platform_id == "macos") monkeypatch.setattr("extra_platforms.is_windows", lambda: platform_id == "windows") for name in ("LOCALAPPDATA", "XDG_CACHE_HOME"): monkeypatch.delenv(name, raising=False) # Path.home() reads HOME on POSIX and USERPROFILE on Windows. monkeypatch.setenv("HOME", "/home/fig") monkeypatch.setenv("USERPROFILE", "/home/fig") for name, value in environ.items(): monkeypatch.setenv(name, value) assert get_cache_dir("plum") == Path(expected)
[docs] def test_install_resolves_home_at_call_time(tmp_path, monkeypatch): """The spec lands under the ``HOME`` in force at the call, not at import. With no ``XDG_CONFIG_HOME`` set, the spec directory derives from the home directory. A test fixture or a packaging script pointing ``HOME`` elsewhere for one invocation is honored, which a path expanded at import cannot do. """ monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) written = install_carapace_spec(weather, prog_name="weather") assert written == tmp_path / ".config" / "carapace" / "specs" / "weather.yaml" assert written.is_file()
[docs] @pytest.mark.parametrize( ("extra_flags", "message"), [ (["--man"], "mutually exclusive"), (["--params"], "mutually exclusive"), ], ) def test_wrap_carapace_mutually_exclusive(runner, greet_script, extra_flags, message): result = runner.invoke( demo, ["wrap", "--help-format", "carapace", *extra_flags, greet_script], color=False, ) assert result.exit_code == 2 assert message in result.output
[docs] def test_wrap_install_requires_an_installable_format(runner, greet_script): """A destination only means something for a rendering someone installs.""" result = runner.invoke(demo, ["wrap", "--install", greet_script], color=False) assert result.exit_code == 2 assert "--install requires --help-format with one of: carapace, man." in ( result.output )
# -- Real carapace-spec engine (integration) ---------------------------------- # Pinned carapace-spec release exercised by the integration test below. CARAPACE_SPEC_VERSION = "1.7.0" # platform.machine() (lower-cased) -> carapace-spec release arch token. CARAPACE_ARCH = { "aarch64": "arm64", "amd64": "amd64", "arm64": "arm64", "x86_64": "amd64", } # A self-contained weather CLI exercising each dynamic shell_complete shape: a # root-level option, a subcommand option, and a subcommand argument. Invoked with # --dump-carapace-spec it prints its own spec; otherwise it answers the completion # the spec's dynamic macros call back into. CARAPACE_RUNNER = '''\ import sys import click import cloup from click.shell_completion import CompletionItem import click_extra # noqa: F401 Registers the carapace completion backend. def complete_region(ctx, param, incomplete): return [CompletionItem("north"), CompletionItem("south")] def complete_city(ctx, param, incomplete): return [CompletionItem("paris", help="France"), CompletionItem("oslo")] def complete_zone(ctx, param, incomplete): return [CompletionItem("alpha"), CompletionItem("bravo")] @cloup.group() @click.option("--region", shell_complete=complete_region, help="Region.") def weather(region): """Weather tools.""" @weather.command() @click.option("--city", shell_complete=complete_city, help="City.") @click.argument("zone", shell_complete=complete_zone) def forecast(city, zone): """Forecast for a zone.""" if __name__ == "__main__": if "--dump-carapace-spec" in sys.argv: from click_extra.carapace import dump_carapace_spec sys.stdout.write(dump_carapace_spec(weather, prog_name="weather")) else: weather(prog_name="weather") '''
[docs] @pytest.mark.network @pytest.mark.once def test_spec_drives_real_carapace_engine(tmp_path): """Feed a generated spec to the real carapace-spec binary, end-to-end. The tests above simulate Carapace at the CarapaceComplete level. This one downloads a pinned carapace-spec release and drives it through each dynamic shape: a root option, a subcommand option, and a subcommand argument. It guards the contract between the emitted macros and Carapace's real traverse/exec/parse behavior, which the simulation cannot see: Carapace strips parent command names from the words it forwards, and withholds the flag being completed entirely, so the macros must bake both back in. ```{attention} Downloads the carapace-spec binary from GitHub and executes it, hence the `network` mark (and `once`, to run on a single CI runner). The dynamic macro shells out, so the test only runs on POSIX platforms. ``` """ system = platform.system().lower() arch = CARAPACE_ARCH.get(platform.machine().lower()) if system not in ("linux", "darwin"): pytest.skip(f"dynamic macro is POSIX-shell only, not {system}") if arch is None: pytest.skip(f"no carapace-spec asset for {platform.machine()}") # Download the pinned release archive from GitHub. asset = f"carapace-spec_{CARAPACE_SPEC_VERSION}_{system}_{arch}.tar.gz" url = ( "https://github.com/carapace-sh/carapace-spec/releases/download/" f"v{CARAPACE_SPEC_VERSION}/{asset}" ) with fetch_or_skip(url) as response: archive_bytes = response.content archive = tmp_path / asset archive.write_bytes(archive_bytes) # Pull just the carapace-spec binary out of the archive by name. Reading the # member by hand (rather than extractall) sidesteps path-traversal and symlink # members and the cross-version tar extraction-filter churn. with tarfile.open(archive, "r:gz") as tar: member = next( ( m for m in tar.getmembers() if m.isfile() and Path(m.name).name == "carapace-spec" ), None, ) assert member is not None, tar.getnames() payload = tar.extractfile(member) assert payload is not None binary = tmp_path / "carapace-spec" binary.write_bytes(payload.read()) binary.chmod(binary.stat().st_mode | stat.S_IXUSR) # Write the CLI and generate its spec through the script's own dump mode. script = tmp_path / "weather_cli.py" script.write_text(CARAPACE_RUNNER, encoding="UTF-8") spec = subprocess.run( [sys.executable, str(script), "--dump-carapace-spec"], capture_output=True, text=True, encoding="UTF-8", check=True, ).stdout spec_file = tmp_path / "weather.yaml" spec_file.write_text(spec, encoding="UTF-8") # The dynamic macro execs the program by bare name, so a `weather` shim that # runs the script under this interpreter must be discoverable on PATH. bindir = tmp_path / "bin" bindir.mkdir() shim = bindir / "weather" shim.write_text( f'#!/bin/sh\nexec "{sys.executable}" "{script}" "$@"\n', encoding="UTF-8" ) shim.chmod(0o755) env = {**os.environ, "PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}"} def complete(*words): """Ask the real engine to complete `weather <words> <TAB>`.""" result = subprocess.run( [str(binary), str(spec_file), "export", "weather", *words], capture_output=True, text=True, encoding="UTF-8", check=False, env=env, ) assert result.returncode == 0, result.stderr return {value["value"] for value in json.loads(result.stdout)["values"]} # Each dynamic shape resolves through the real engine. assert complete("--region", "") == {"north", "south"} assert complete("forecast", "--city", "") == {"paris", "oslo"} assert complete("forecast", "") == {"alpha", "bravo"} # Carapace prefix-filters the full candidate set the callback returns. assert complete("forecast", "--city", "p") == {"paris"}