# 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.
"""Export a Click command tree as a Carapace completion spec.
`Carapace <https://carapace.sh>`_ is a multi-shell completion engine: a single
spec file drives identical completions across Bash, Zsh, Fish, Nushell,
PowerShell, Elvish, Xonsh, Oil and more. This module walks a Click/Cloup command
tree and serializes it to the YAML `carapace-spec
<https://github.com/carapace-sh/carapace-spec>`_ format, answering the request
for native Carapace support in `Click issue #3188
<https://github.com/pallets/click/issues/3188>`_ (closed as out of scope for core
Click, redirected here).
Two completion strategies cooperate:
- **Static.** Choices, file/directory operands and the command hierarchy are
inlined straight into the spec. These complete with no process launch and work
in every shell Carapace supports, which is the advantage a spec has over
Carapace's existing bridge to a single shell's native Click completion.
- **Dynamic.** A parameter carrying a custom ``shell_complete`` (a callback, or a
:class:`~click.ParamType` that overrides
:meth:`~click.ParamType.shell_complete`) cannot be frozen into the spec, so its
action calls back into the CLI through Carapace's shell macro. The callback
reuses Click's own completion machinery via :class:`CarapaceComplete`.
.. note::
Dynamic completion needs the :class:`CarapaceComplete` class registered in
the target process, which happens on ``import click_extra``. A CLI built with
Click Extra gets it for free; a plain Click CLI would have to import
``click_extra`` for the dynamic callback to resolve. Static completion has no
such requirement.
The dataclasses below mirror the upstream ``carapace-spec`` `JSON schema
<https://github.com/carapace-sh/carapace-spec/blob/master/schema.json>`_; the
flag-key grammar and macro contract are taken from that project's ``flag.go`` and
``core.go``.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from importlib import metadata
from pathlib import Path
import click
from click.shell_completion import (
ShellComplete,
add_completion_class,
split_arg_string,
)
from cloup.constraints import mutually_exclusive
from .commands import DEFAULT_HELP_NAMES, default_params
from .parameters import (
full_short_help,
is_repeatable,
iter_subcommands,
make_resilient_context,
missing_extra_message,
option_value_kind,
param_spellings,
short_long_opts,
)
try:
import yaml
except ImportError:
# PyYAML ships behind the ``carapace`` extra: importing this module stays
# cheap, and only the YAML-serializing entry points raise (see _require_yaml).
yaml = None # type: ignore[assignment]
TYPE_CHECKING = False
if TYPE_CHECKING:
from click import Command, Context, Parameter
CARAPACE_SPECS_DIR = Path("~/.config/carapace/specs").expanduser()
"""User spec directory Carapace loads on startup.
Writing ``<prog>.yaml`` here (see :func:`install_carapace_spec`) is all it takes
for Carapace to pick up a CLI's completions. Mirrors the ``$XDG_CONFIG_HOME``
default documented by Carapace; an explicit ``XDG_CONFIG_HOME`` is honored by
:func:`install_carapace_spec` at call time rather than baked in here.
"""
CARAPACE_DOCS_URL = "https://kdeldycke.github.io/click-extra/carapace.html"
"""Documentation page stamped into every generated spec's header comment, so a
reader of the raw YAML knows where the feature is documented."""
def _require_yaml() -> None:
"""Raise a pointed error when the optional PyYAML dependency is missing."""
if yaml is None:
raise ImportError(
missing_extra_message("carapace", subject="Carapace spec serialization")
)
def _clean_description(text: str | None) -> str:
"""Collapse a help string to a single Carapace-friendly description line.
Strips reST inline-literal backticks (Click stores docstrings verbatim, so
``"``...``"`` markup leaks through), keeps only the first paragraph and
squashes internal whitespace. Carapace truncates long descriptions itself.
"""
if not text:
return ""
paragraph = text.replace("``", "").split("\n\n", 1)[0]
return " ".join(paragraph.split())
# --- flag-key construction --------------------------------------------------
def _flag_key(
opts: list[str],
*,
value: bool,
optarg: bool = False,
repeatable: bool = False,
) -> str:
"""Build a Carapace flag key from option spellings and modifiers.
Reproduces ``carapace-spec``'s own ``Flag.format()`` grammar: the short
spelling first, then the long spelling joined by ``", "``, then ``?`` for an
optional value or ``=`` for a mandatory one, then a trailing ``*`` when the
flag is repeatable. So a plain switch is ``--foo``, a valued option
``--foo=``, an optional-value option ``--foo?``, a counter ``--foo*`` and a
repeatable valued option ``--foo=*``.
"""
short, long = short_long_opts(opts)
key = short
if short and long:
key += ", "
key += long
if optarg:
key += "?"
elif value:
key += "="
if repeatable:
key += "*"
return key
def _flag_name(opts: list[str]) -> str:
"""Carapace completion key for a flag: long spelling, else short, no dashes."""
short, long = short_long_opts(opts)
return (long or short).lstrip("-")
# --- completion action derivation -------------------------------------------
def _env_var(prog_name: str) -> str:
"""Completion env var Click derives from a program name (``_FOO_COMPLETE``)."""
return f"_{prog_name}_COMPLETE".replace("-", "_").upper()
def _dynamic_action(prog_name: str) -> str:
"""A Carapace shell-macro action that calls back into the CLI for completion.
Carapace's default ``$(...)`` macro runs ``sh -c '<script>' -- <words>``,
exposing the already-typed words as ``$*`` and parsing each output line as
``value\\tdescription``. The script re-invokes the program in Click's
completion mode (:class:`CarapaceComplete`, registered as the ``carapace``
shell), which prints exactly that. Carapace prefix-filters the result, so the
callback returns the full candidate set.
.. warning::
The macro string is derived from ``carapace-spec``'s ``core.go`` shell
macro rather than a live run. It is the one part of the export that
warrants a smoke test against an installed ``carapace`` binary.
"""
return (
f'$(env "{_env_var(prog_name)}=carapace_complete" '
f'"COMP_WORDS={prog_name} $*" {prog_name} 2>/dev/null)'
)
def _static_action(param_type: click.ParamType) -> list[str] | None:
"""Inline completion action for a statically-knowable type, else ``None``.
Maps choice-like types to their literal values and path/file types to
Carapace's ``$files`` / ``$directories`` macros, mirroring how Click's own
:meth:`Choice.shell_complete <click.Choice.shell_complete>` and
:meth:`Path.shell_complete <click.Path.shell_complete>` behave.
"""
choices = getattr(param_type, "choices", None)
if choices:
return [str(choice) for choice in choices]
if isinstance(param_type, click.Path):
if param_type.dir_okay and not param_type.file_okay:
return ["$directories"]
return ["$files"]
if isinstance(param_type, click.File):
return ["$files"]
return None
def _overrides_shell_complete(param_type: click.ParamType) -> bool:
"""Whether a type provides a non-static custom ``shell_complete`` override."""
if isinstance(param_type, (click.Choice, click.Path, click.File)):
return False
if getattr(param_type, "choices", None):
return False
return type(param_type).shell_complete is not click.ParamType.shell_complete
def _param_action(param: Parameter, prog_name: str) -> list[str]:
"""Resolve the Carapace completion action for one parameter.
An explicit ``shell_complete=`` callback always routes to the dynamic macro;
otherwise a statically-knowable type is inlined; otherwise a type that
overrides ``shell_complete`` falls back to the dynamic macro. Anything else
yields an empty action (no completion offered).
"""
if getattr(param, "_custom_shell_complete", None) is not None:
return [_dynamic_action(prog_name)]
static = _static_action(param.type)
if static is not None:
return static
if _overrides_shell_complete(param.type):
return [_dynamic_action(prog_name)]
return []
# --- spec data model --------------------------------------------------------
[docs]
@dataclass
class CarapaceCompletion:
"""The ``completion`` block of a command: per-flag and positional actions."""
flag: dict[str, list[str]] = field(default_factory=dict)
"""Map of flag name (long spelling, no dashes) to its completion action."""
positional: list[list[str]] = field(default_factory=list)
"""Per-position completion actions for fixed-arity arguments, in order."""
positionalany: list[str] = field(default_factory=list)
"""Completion action repeated for every trailing variadic argument."""
[docs]
def is_empty(self) -> bool:
"""Whether nothing here is worth serializing."""
return not (self.flag or any(self.positional) or self.positionalany)
[docs]
def to_dict(self) -> dict:
"""Serialize to the ``carapace-spec`` ``completion`` mapping, dropping
empty members and trailing empty positional slots."""
out: dict = {}
if self.flag:
out["flag"] = self.flag
positional = list(self.positional)
while positional and not positional[-1]:
positional.pop()
if positional:
out["positional"] = positional
if self.positionalany:
out["positionalany"] = self.positionalany
return out
[docs]
@dataclass
class CarapaceCommand:
"""One node of a Carapace spec: a command and its flags, completions and
subcommands, mirroring the upstream ``Command`` schema."""
name: str
"""The command's invocation name (the root carries the program name)."""
description: str = ""
"""One-line command description (NAME-section short help)."""
aliases: tuple[str, ...] = ()
"""Alternative names the command also answers to (from Cloup)."""
hidden: bool = False
"""Whether the command is hidden from listings (still completable)."""
flags: dict[str, str] = field(default_factory=dict)
"""Map of flag key (with shape suffixes) to its description."""
persistentflags: dict[str, str] = field(default_factory=dict)
"""Flags inherited by every subcommand (the root's default option set)."""
exclusiveflags: list[list[str]] = field(default_factory=list)
"""Groups of mutually-exclusive flag names (from Cloup constraints)."""
completion: CarapaceCompletion = field(default_factory=CarapaceCompletion)
"""Static and dynamic completion actions for this command's parameters."""
commands: list[CarapaceCommand] = field(default_factory=list)
"""Nested subcommands."""
[docs]
def to_dict(self) -> dict:
"""Serialize to a ``carapace-spec`` command mapping, dropping empties.
Only ``name`` is required by the schema, so every other member is
omitted when empty to keep the YAML compact.
"""
out: dict = {"name": self.name}
if self.description:
out["description"] = self.description
if self.aliases:
out["aliases"] = list(self.aliases)
if self.hidden:
out["hidden"] = True
if self.flags:
out["flags"] = self.flags
if self.persistentflags:
out["persistentflags"] = self.persistentflags
if self.exclusiveflags:
out["exclusiveflags"] = self.exclusiveflags
completion = self.completion.to_dict()
if completion:
out["completion"] = completion
if self.commands:
out["commands"] = [sub.to_dict() for sub in self.commands]
return out
# --- extraction -------------------------------------------------------------
def _default_param_opts() -> frozenset[str]:
"""All option spellings injected on every Click Extra command by default.
Used to route the root command's default options into ``persistentflags``
(so Carapace offers them under every subcommand) and to skip the same
options on subcommands, where they would otherwise be duplicated. A plain
Click CLI carries none of these, so its ``persistentflags`` stays empty.
"""
opts = set(DEFAULT_HELP_NAMES)
for param in default_params():
opts.update(param_spellings(param))
return frozenset(opts)
def _exclusive_flag_groups(command: Command) -> list[list[str]]:
"""Mutually-exclusive flag-name groups from Cloup option-group constraints.
Only ``@option_group(..., constraint=mutually_exclusive)`` is exported:
Carapace's ``exclusiveflags`` has no equivalent for Cloup's richer
constraints (``RequireAtLeast``, ``RequireExactly``, ``If``), which are
dropped.
"""
groups: list[list[str]] = []
for option_group in getattr(command, "option_groups", ()):
if getattr(option_group, "constraint", None) is not mutually_exclusive:
continue
names = [
_flag_name(option.opts)
for option in option_group.options
if not getattr(option, "hidden", False)
]
if len(names) > 1:
groups.append(names)
return groups
def _add_option(
node: CarapaceCommand,
param: Parameter,
*,
persistent: bool,
root_name: str,
) -> None:
"""Encode one Click option into ``flags``/``persistentflags`` and completion.
A boolean flag with a secondary spelling (``--foo`` / ``--no-foo``) is split
into two independent Carapace flags, since the spec has no negation primitive.
Only value-taking options contribute a ``completion.flag`` action. Dynamic
actions reference ``root_name``, the binary Carapace dispatches on.
"""
flags = node.persistentflags if persistent else node.flags
description = _clean_description(getattr(param, "help", None))
kind = option_value_kind(param)
value = kind != "flag"
optarg = kind == "optional"
repeatable = is_repeatable(param)
flags[_flag_key(param.opts, value=value, optarg=optarg, repeatable=repeatable)] = (
description
)
# Boolean flags expose their negative spelling as a separate switch.
if getattr(param, "secondary_opts", None):
flags[_flag_key(param.secondary_opts, value=False, repeatable=False)] = (
description
)
if value:
action = _param_action(param, root_name)
if action:
node.completion.flag[_flag_name(param.opts)] = action
[docs]
def extract_carapace_command(
command: Command,
ctx: Context,
*,
is_root: bool,
default_opts: frozenset[str],
inherited_opts: frozenset[str],
root_name: str,
) -> CarapaceCommand:
"""Build a :class:`CarapaceCommand` from a Click command and its context.
The context must have been created for ``command`` (typically via
:meth:`click.Command.make_context` with ``resilient_parsing=True``).
Subcommands are discovered dynamically and recursed into.
``default_opts`` is the abstract set of spellings Click Extra injects on every
command; on the root, options drawn from it become ``persistentflags``.
``inherited_opts`` is what an ancestor actually published as persistent, so a
subcommand drops exactly those (Carapace already offers them) and keeps the
rest, including a same-named option the root never carried. ``root_name`` is
the binary Carapace dispatches on, used to build dynamic callback macros.
"""
node = CarapaceCommand(
name=ctx.info_name or command.name or "",
description=full_short_help(command),
aliases=tuple(getattr(command, "aliases", ()) or ()),
hidden=bool(getattr(command, "hidden", False)),
)
positional: list[list[str]] = []
persistent_spellings = set(inherited_opts)
for param in command.get_params(ctx):
if isinstance(param, click.Argument):
action = _param_action(param, root_name)
if param.nargs == -1:
node.completion.positionalany = action
else:
positional.extend([action] * max(param.nargs, 1))
continue
if is_root and set(param.opts) <= default_opts:
# A root default option: publish it once as persistent so every
# subcommand inherits it, and remember its spellings to skip below.
_add_option(node, param, persistent=True, root_name=root_name)
persistent_spellings.update(param_spellings(param))
elif set(param.opts) <= inherited_opts:
# Already offered by an ancestor's persistent flags: do not repeat.
continue
else:
_add_option(node, param, persistent=False, root_name=root_name)
node.completion.positional = positional
node.exclusiveflags = _exclusive_flag_groups(command)
child_inherited = frozenset(persistent_spellings)
for sub_name, sub in iter_subcommands(command, ctx, skip_hidden=False):
sub_ctx = make_resilient_context(sub, sub_name, parent=ctx)
node.commands.append(
extract_carapace_command(
sub,
sub_ctx,
is_root=False,
default_opts=default_opts,
inherited_opts=child_inherited,
root_name=root_name,
)
)
return node
[docs]
def to_carapace_spec(
command: Command,
prog_name: str | None = None,
ctx: Context | None = None,
) -> dict:
"""Build the Carapace spec for a command tree as a plain dict.
Reuses ``ctx`` when given (the live invocation context), otherwise builds a
throwaway one with ``resilient_parsing=True``. The returned mapping conforms
to the ``carapace-spec`` schema and is ready to hand to ``yaml.safe_dump``.
"""
name = prog_name or command.name or ""
if ctx is None:
ctx = make_resilient_context(command, name)
node = extract_carapace_command(
command,
ctx,
is_root=True,
default_opts=_default_param_opts(),
inherited_opts=frozenset(),
root_name=name,
)
# The root node's name follows the program name, not the context's.
node.name = name
return node.to_dict()
def _generator_tag() -> str:
"""Provenance tag stamped into the generated spec's header comment."""
try:
return f"Click Extra {metadata.version('click-extra')}"
except metadata.PackageNotFoundError:
return "Click Extra"
[docs]
def dump_carapace_spec(
command: Command,
prog_name: str | None = None,
ctx: Context | None = None,
*,
invocation: str | None = None,
) -> str:
"""Serialize a command tree to a Carapace spec YAML string.
Requires the optional PyYAML dependency (``click-extra[carapace]``). The
output keeps Click's declaration order rather than sorting keys, so flags and
subcommands line up with the help screen, and is prefixed with a provenance
header: the generator version, the ``invocation`` command line that produced
it (when given), and a link to the documentation.
"""
_require_yaml()
spec = to_carapace_spec(command, prog_name, ctx)
body = yaml.safe_dump(spec, sort_keys=False, allow_unicode=True, width=88)
header_lines = [f"# Generated by {_generator_tag()}. Do not edit by hand."]
if invocation:
header_lines.append(f"# $ {invocation}")
header_lines.append(f"# Documentation: {CARAPACE_DOCS_URL}")
header = "".join(f"{line}\n" for line in header_lines)
return header + body
[docs]
def write_carapace_spec(
command: Command,
target: str | Path,
prog_name: str | None = None,
*,
invocation: str | None = None,
) -> Path:
"""Render the spec and write it to ``target``, returning the written path.
Creates parent directories as needed. ``invocation`` is recorded in the
header comment (see :func:`dump_carapace_spec`).
"""
path = Path(target)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
dump_carapace_spec(command, prog_name, invocation=invocation),
encoding="utf-8",
)
return path
[docs]
def install_carapace_spec(
command: Command,
prog_name: str | None = None,
*,
invocation: str | None = None,
) -> Path:
"""Write the spec into Carapace's user spec directory.
Targets ``$XDG_CONFIG_HOME/carapace/specs/<prog>.yaml`` (honoring an explicit
``XDG_CONFIG_HOME``), which Carapace loads on startup. Returns the path.
"""
name = prog_name or command.name or "cli"
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg).expanduser() / "carapace" / "specs" if xdg else CARAPACE_SPECS_DIR
return write_carapace_spec(
command, base / f"{name}.yaml", prog_name=name, invocation=invocation
)
# --- dynamic completion backend ---------------------------------------------
[docs]
@add_completion_class
class CarapaceComplete(ShellComplete):
"""Click completion backend that emits Carapace's value/description lines.
Registered as the ``carapace`` shell, so ``_FOO_COMPLETE=carapace_complete``
makes a Click CLI print completions in the ``value\\tdescription`` text format
Carapace's shell macro parses. This is the callback target of the dynamic
actions emitted by :func:`_dynamic_action`; it reuses Click's own
:meth:`~click.shell_completion.ShellComplete.get_completions`, so a parameter's
custom ``shell_complete`` is honored verbatim.
The current word is left to Carapace to filter, so completion args are the
already-typed words with an empty incomplete value.
"""
name = "carapace"
"""Shell name Click registers this backend under (the ``carapace_complete``
completion instruction)."""
source_template = ""
[docs]
def source(self) -> str:
"""No-op source step: Carapace, not a shell, consumes this backend."""
return ""
[docs]
def get_completion_args(self) -> tuple[list[str], str]:
"""Read the words Carapace passed via ``COMP_WORDS`` (program name first).
Returns the typed arguments with an empty incomplete value; Carapace
applies its own prefix filtering to the candidates we return.
"""
words = split_arg_string(os.environ.get("COMP_WORDS", ""))
args = words[1:] if words else []
return args, ""