|
17 | 17 | from __future__ import annotations |
18 | 18 |
|
19 | 19 | import json |
| 20 | +import os |
| 21 | +import platform |
| 22 | +import stat |
| 23 | +import subprocess |
| 24 | +import sys |
| 25 | +import tarfile |
20 | 26 | from pathlib import Path |
21 | 27 |
|
22 | 28 | import click |
23 | 29 | import cloup |
24 | 30 | import pytest |
| 31 | +import requests |
25 | 32 | import yaml |
26 | 33 | from click.shell_completion import CompletionItem, get_completion_class |
27 | 34 | from cloup.constraints import mutually_exclusive |
@@ -218,10 +225,12 @@ def test_dynamic_action_emitted(flag_name): |
218 | 225 | macro = action[0] |
219 | 226 | assert macro.startswith("$(") |
220 | 227 | assert "_WEATHER_COMPLETE=carapace_complete" in macro |
221 | | - # The macro bakes the full command path (root + subcommand), not just the |
222 | | - # root: Carapace strips parent command names from the words it forwards to |
223 | | - # the macro, so the path must be restored here for Click to resolve forecast. |
224 | | - assert "COMP_WORDS=weather forecast $*" in macro |
| 228 | + # The macro bakes the full command path (root + subcommand) AND the option |
| 229 | + # spelling. Carapace forwards neither to the macro: it strips parent command |
| 230 | + # names from the words, and routes a flag's value completion to this per-flag |
| 231 | + # action without putting the flag in those words. Both are restored so Click |
| 232 | + # resolves the right option's value under the right subcommand. |
| 233 | + assert f"COMP_WORDS=weather forecast $* --{flag_name}" in macro |
225 | 234 |
|
226 | 235 |
|
227 | 236 | # -- persistentflags ---------------------------------------------------------- |
@@ -323,21 +332,26 @@ def test_complete_reuses_click_machinery(): |
323 | 332 |
|
324 | 333 |
|
325 | 334 | def test_subcommand_dynamic_completion_resolves_end_to_end(monkeypatch): |
326 | | - """The COMP_WORDS a subcommand's generated macro builds must let the backend |
327 | | - resolve that subcommand's callback, not the root command. |
328 | | -
|
329 | | - Carapace strips parent command names from the words it forwards to the macro, |
330 | | - so for ``weather forecast --city`` it passes only ``--city``. The macro |
331 | | - restores the baked path; rebuilding COMP_WORDS the same way and feeding it |
332 | | - back must yield the forecast callback's values. Pinning only the root name |
333 | | - (the original behavior) would reconstruct ``weather --city``, resolve it |
334 | | - against the root, and complete the subcommand list instead. |
| 335 | + """The COMP_WORDS a subcommand flag's generated macro builds must let the |
| 336 | + backend resolve that option's value, not the root command. |
| 337 | +
|
| 338 | + When completing a subcommand flag's value, Carapace forwards an empty word |
| 339 | + list to the macro: it strips the parent command names, and keeps the flag |
| 340 | + being completed in its own state rather than passing it through. So the macro |
| 341 | + bakes both the command path and the flag spelling, and ``$*`` expands to |
| 342 | + nothing. This reconstructs the resulting COMP_WORDS from the generated macro |
| 343 | + and confirms the backend resolves the forecast callback. The original |
| 344 | + root-only, flag-less macro would have reconstructed bare ``weather`` and |
| 345 | + completed the subcommand list instead. |
335 | 346 | """ |
336 | 347 | macro = FORECAST["completion"]["flag"]["city"][0] |
337 | | - baked_path = macro.split("COMP_WORDS=", 1)[1].split(" $*", 1)[0] |
338 | | - monkeypatch.setenv("COMP_WORDS", f"{baked_path} --city") |
| 348 | + # The macro's COMP_WORDS template, with $* expanded to the empty word list |
| 349 | + # Carapace passes for a flag value. |
| 350 | + template = macro.split("COMP_WORDS=", 1)[1].split('"', 1)[0] |
| 351 | + monkeypatch.setenv("COMP_WORDS", template.replace("$*", "")) |
339 | 352 | comp = CarapaceComplete(weather, {}, "weather", "_WEATHER_COMPLETE") |
340 | 353 | args, incomplete = comp.get_completion_args() |
| 354 | + assert args == ["forecast", "--city"] |
341 | 355 | values = {item.value for item in comp.get_completions(args, incomplete)} |
342 | 356 | assert values == {"paris", "oslo"} |
343 | 357 |
|
@@ -416,3 +430,162 @@ def test_wrap_install_requires_carapace(runner, greet_script): |
416 | 430 | result = runner.invoke(demo, ["wrap", "--install", greet_script], color=False) |
417 | 431 | assert result.exit_code == 2 |
418 | 432 | assert "--install requires --carapace" in result.output |
| 433 | + |
| 434 | + |
| 435 | +# -- Real carapace-spec engine (integration) ---------------------------------- |
| 436 | + |
| 437 | +# Pinned carapace-spec release exercised by the integration test below. |
| 438 | +CARAPACE_SPEC_VERSION = "1.7.0" |
| 439 | + |
| 440 | +# platform.machine() (lower-cased) -> carapace-spec release arch token. |
| 441 | +CARAPACE_ARCH = { |
| 442 | + "aarch64": "arm64", |
| 443 | + "amd64": "amd64", |
| 444 | + "arm64": "arm64", |
| 445 | + "x86_64": "amd64", |
| 446 | +} |
| 447 | + |
| 448 | +# A self-contained weather CLI exercising each dynamic shell_complete shape: a |
| 449 | +# root-level option, a subcommand option, and a subcommand argument. Invoked with |
| 450 | +# --dump-carapace-spec it prints its own spec; otherwise it answers the completion |
| 451 | +# the spec's dynamic macros call back into. |
| 452 | +CARAPACE_RUNNER = '''\ |
| 453 | +import sys |
| 454 | +
|
| 455 | +import click |
| 456 | +import cloup |
| 457 | +from click.shell_completion import CompletionItem |
| 458 | +
|
| 459 | +import click_extra # noqa: F401 Registers the carapace completion backend. |
| 460 | +
|
| 461 | +
|
| 462 | +def complete_region(ctx, param, incomplete): |
| 463 | + return [CompletionItem("north"), CompletionItem("south")] |
| 464 | +
|
| 465 | +
|
| 466 | +def complete_city(ctx, param, incomplete): |
| 467 | + return [CompletionItem("paris", help="France"), CompletionItem("oslo")] |
| 468 | +
|
| 469 | +
|
| 470 | +def complete_zone(ctx, param, incomplete): |
| 471 | + return [CompletionItem("alpha"), CompletionItem("bravo")] |
| 472 | +
|
| 473 | +
|
| 474 | +@cloup.group() |
| 475 | +@click.option("--region", shell_complete=complete_region, help="Region.") |
| 476 | +def weather(region): |
| 477 | + """Weather tools.""" |
| 478 | +
|
| 479 | +
|
| 480 | +@weather.command() |
| 481 | +@click.option("--city", shell_complete=complete_city, help="City.") |
| 482 | +@click.argument("zone", shell_complete=complete_zone) |
| 483 | +def forecast(city, zone): |
| 484 | + """Forecast for a zone.""" |
| 485 | +
|
| 486 | +
|
| 487 | +if __name__ == "__main__": |
| 488 | + if "--dump-carapace-spec" in sys.argv: |
| 489 | + from click_extra.carapace import dump_carapace_spec |
| 490 | +
|
| 491 | + sys.stdout.write(dump_carapace_spec(weather, prog_name="weather")) |
| 492 | + else: |
| 493 | + weather(prog_name="weather") |
| 494 | +''' |
| 495 | + |
| 496 | + |
| 497 | +@pytest.mark.network |
| 498 | +@pytest.mark.once |
| 499 | +def test_spec_drives_real_carapace_engine(tmp_path): |
| 500 | + """Feed a generated spec to the real carapace-spec binary, end-to-end. |
| 501 | +
|
| 502 | + The tests above simulate Carapace at the CarapaceComplete level. This one |
| 503 | + downloads a pinned carapace-spec release and drives it through each dynamic |
| 504 | + shape: a root option, a subcommand option, and a subcommand argument. It |
| 505 | + guards the contract between the emitted macros and Carapace's real |
| 506 | + traverse/exec/parse behavior, which the simulation cannot see: Carapace strips |
| 507 | + parent command names from the words it forwards, and withholds the flag being |
| 508 | + completed entirely, so the macros must bake both back in. |
| 509 | +
|
| 510 | + .. attention:: |
| 511 | + Downloads the carapace-spec binary from GitHub and executes it, hence the |
| 512 | + ``network`` mark (and ``once``, to run on a single CI runner). The dynamic |
| 513 | + macro shells out, so the test only runs on POSIX platforms. |
| 514 | + """ |
| 515 | + system = platform.system().lower() |
| 516 | + arch = CARAPACE_ARCH.get(platform.machine().lower()) |
| 517 | + if system not in ("linux", "darwin"): |
| 518 | + pytest.skip(f"dynamic macro is POSIX-shell only, not {system}") |
| 519 | + if arch is None: |
| 520 | + pytest.skip(f"no carapace-spec asset for {platform.machine()}") |
| 521 | + |
| 522 | + # Download the pinned release archive from GitHub. |
| 523 | + asset = f"carapace-spec_{CARAPACE_SPEC_VERSION}_{system}_{arch}.tar.gz" |
| 524 | + url = ( |
| 525 | + "https://github.com/carapace-sh/carapace-spec/releases/download/" |
| 526 | + f"v{CARAPACE_SPEC_VERSION}/{asset}" |
| 527 | + ) |
| 528 | + with requests.get(url, timeout=60) as response: |
| 529 | + assert response.ok, f"failed to download {url}" |
| 530 | + archive_bytes = response.content |
| 531 | + |
| 532 | + archive = tmp_path / asset |
| 533 | + archive.write_bytes(archive_bytes) |
| 534 | + |
| 535 | + # Pull just the carapace-spec binary out of the archive by name. Reading the |
| 536 | + # member by hand (rather than extractall) sidesteps path-traversal and symlink |
| 537 | + # members and the cross-version tar extraction-filter churn. |
| 538 | + with tarfile.open(archive, "r:gz") as tar: |
| 539 | + member = next( |
| 540 | + ( |
| 541 | + m |
| 542 | + for m in tar.getmembers() |
| 543 | + if m.isfile() and Path(m.name).name == "carapace-spec" |
| 544 | + ), |
| 545 | + None, |
| 546 | + ) |
| 547 | + assert member is not None, tar.getnames() |
| 548 | + payload = tar.extractfile(member) |
| 549 | + assert payload is not None |
| 550 | + binary = tmp_path / "carapace-spec" |
| 551 | + binary.write_bytes(payload.read()) |
| 552 | + binary.chmod(binary.stat().st_mode | stat.S_IXUSR) |
| 553 | + |
| 554 | + # Write the CLI and generate its spec through the script's own dump mode. |
| 555 | + script = tmp_path / "weather_cli.py" |
| 556 | + script.write_text(CARAPACE_RUNNER) |
| 557 | + spec = subprocess.run( |
| 558 | + [sys.executable, str(script), "--dump-carapace-spec"], |
| 559 | + capture_output=True, |
| 560 | + text=True, |
| 561 | + check=True, |
| 562 | + ).stdout |
| 563 | + spec_file = tmp_path / "weather.yaml" |
| 564 | + spec_file.write_text(spec) |
| 565 | + |
| 566 | + # The dynamic macro execs the program by bare name, so a `weather` shim that |
| 567 | + # runs the script under this interpreter must be discoverable on PATH. |
| 568 | + bindir = tmp_path / "bin" |
| 569 | + bindir.mkdir() |
| 570 | + shim = bindir / "weather" |
| 571 | + shim.write_text(f'#!/bin/sh\nexec "{sys.executable}" "{script}" "$@"\n') |
| 572 | + shim.chmod(0o755) |
| 573 | + env = {**os.environ, "PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}"} |
| 574 | + |
| 575 | + def complete(*words): |
| 576 | + """Ask the real engine to complete `weather <words> <TAB>`.""" |
| 577 | + result = subprocess.run( |
| 578 | + [str(binary), str(spec_file), "export", "weather", *words], |
| 579 | + capture_output=True, |
| 580 | + text=True, |
| 581 | + env=env, |
| 582 | + ) |
| 583 | + assert result.returncode == 0, result.stderr |
| 584 | + return {value["value"] for value in json.loads(result.stdout)["values"]} |
| 585 | + |
| 586 | + # Each dynamic shape resolves through the real engine. |
| 587 | + assert complete("--region", "") == {"north", "south"} |
| 588 | + assert complete("forecast", "--city", "") == {"paris", "oslo"} |
| 589 | + assert complete("forecast", "") == {"alpha", "bravo"} |
| 590 | + # Carapace prefix-filters the full candidate set the callback returns. |
| 591 | + assert complete("forecast", "--city", "p") == {"paris"} |
0 commit comments