Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Unreleased

- Add built-in shell completion support for PowerShell (Windows PowerShell
5.1+ and pwsh 7+) alongside the existing `bash`, `zsh`, and `fish`
completers. Use `_FOO_BAR_COMPLETE=powershell_source foo-bar` to generate
the completion script.
- Supported versions of Windows enable ANSI terminal styles by default.
Colorama is no longer a dependency and is not used. {issue}`2986` {pr}`3505`
- {class}`Argument` accepts a `help` parameter, and help output includes
Expand Down
28 changes: 27 additions & 1 deletion docs/shell-completion.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ depending on the shell you are using. Click will output it when called with `_{F
`{shell}_source`. `{FOO_BAR}` is the executable name in uppercase with dashes replaced by underscores. It is
conventional but not strictly required for environment variable names to be in upper case. This convention helps
distinguish environment variables from regular shell variables and commands, making scripts and configuration files more
readable and easier to maintain. The built-in shells are `bash`, `zsh`, and `fish`.
readable and easier to maintain. The built-in shells are `bash`, `zsh`, `fish`, and `powershell`.

Provide your users with the following instructions customized to your program name. This uses `foo-bar` as an example.

Expand Down Expand Up @@ -62,6 +62,16 @@ Provide your users with the following instructions customized to your program na

This is the same file used for the activation script method
below. For Fish it's probably always easier to use that method.

.. group-tab:: PowerShell

Add this to your PowerShell profile (the path of ``$PROFILE``):

.. code-block:: powershell

$env:_FOO_BAR_COMPLETE = 'powershell_source'
foo-bar | Out-String | Invoke-Expression
Remove-Item Env:_FOO_BAR_COMPLETE
```

Using `eval` means that the command is invoked and evaluated every time a shell is started, which can delay shell
Expand Down Expand Up @@ -106,6 +116,22 @@ of time and distribute them with your program to save your users a step.
.. code-block:: fish

_FOO_BAR_COMPLETE=fish_source foo-bar > ~/.config/fish/completions/foo-bar.fish

.. group-tab:: PowerShell

Save the script somewhere.

.. code-block:: powershell

$env:_FOO_BAR_COMPLETE = 'powershell_source'
foo-bar | Out-File -Encoding utf8 ~/.foo-bar-complete.ps1
Remove-Item Env:_FOO_BAR_COMPLETE

Source the file in your PowerShell profile (the path of ``$PROFILE``):

.. code-block:: powershell

. ~/.foo-bar-complete.ps1
```

After modifying the shell config, you need to start a new shell in order for the changes to be loaded.
Expand Down
97 changes: 97 additions & 0 deletions src/click/shell_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,68 @@ def __getattr__(self, name: str) -> t.Any:
"(%(complete_func)s)";
"""

# Compatible with Windows PowerShell 5.1+ and PowerShell (pwsh) 7+.
# Uses Register-ArgumentCompleter -Native, which receives the command AST
# as parsed by PowerShell. The command text is forwarded verbatim through
# COMP_WORDS so the Python side can reuse the same shlex-based splitting
# used by the bash/zsh completers.
_SOURCE_POWERSHELL = """\
Register-ArgumentCompleter -Native -CommandName %(prog_name)s -ScriptBlock {
param($wordToComplete, $commandAst, $cursorPosition)

$prev_complete = $env:%(complete_var)s
$prev_words = $env:COMP_WORDS
$prev_cword = $env:COMP_CWORD

$env:%(complete_var)s = 'powershell_complete'
$env:COMP_WORDS = $commandAst.ToString()
if ($wordToComplete) {
$env:COMP_CWORD = $commandAst.CommandElements.Count - 1
} else {
$env:COMP_CWORD = $commandAst.CommandElements.Count
}

try {
$response = & %(prog_name)s 2>$null
} finally {
$env:%(complete_var)s = $prev_complete
$env:COMP_WORDS = $prev_words
$env:COMP_CWORD = $prev_cword
}

if (-not $response) { return }

$prefix = "$wordToComplete*"
$lines = $response -split "`n"
for ($i = 0; $i + 2 -lt $lines.Count; $i += 3) {
$type = $lines[$i]
$value = $lines[$i + 1]
$descr = $lines[$i + 2]
if (-not $type) { continue }

if ($type -eq 'plain') {
$tip = if ($descr -and $descr -ne '_') { $descr } else { $value }
[System.Management.Automation.CompletionResult]::new(
$value, $value, 'ParameterValue', $tip)
} elseif ($type -eq 'dir') {
Get-ChildItem -Directory -Path $prefix -ErrorAction SilentlyContinue |
ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_.FullName, $_.Name, 'ProviderContainer', $_.FullName)
}
} elseif ($type -eq 'file') {
Get-ChildItem -Path $prefix -ErrorAction SilentlyContinue |
ForEach-Object {
$kind = 'ProviderItem'
if ($_.PSIsContainer) { $kind = 'ProviderContainer' }
[System.Management.Automation.CompletionResult]::new(
$_.FullName, $_.Name, $kind, $_.FullName)
}
}
}
}
"""


class _SourceVarsDict(t.TypedDict):
complete_func: str
Expand Down Expand Up @@ -456,9 +518,44 @@ def format_completion(self, item: CompletionItem[str]) -> str:
return f"{item.type},{item.value}"


class PowerShellComplete(ShellComplete):
"""Shell completion for PowerShell (Windows PowerShell 5.1+ and pwsh 7+).

.. versionadded:: 8.5
"""

name: t.ClassVar[str] = "powershell"
source_template: t.ClassVar[str] = _SOURCE_POWERSHELL

def get_completion_args(self) -> tuple[list[str], str]:
cwords = split_arg_string(os.environ["COMP_WORDS"])
cword = int(os.environ["COMP_CWORD"])
args = cwords[1:cword]

try:
incomplete = cwords[cword]
except IndexError:
incomplete = ""

return args, incomplete

def format_completion(self, item: CompletionItem[str]) -> str:
# PowerShell parses the response by splitting on newlines and
# consuming three lines per completion (type, value, help).
# Multi-line help text would break that framing, so collapse
# newlines and carriage returns to spaces. The literal "_"
# sentinel matches what ZshComplete uses for "no help text".
if item.help:
help_ = item.help.replace("\r", " ").replace("\n", " ")
else:
help_ = "_"
return f"{item.type}\n{item.value}\n{help_}"


_available_shells: t.Final[dict[str, type[ShellComplete]]] = {
"bash": BashComplete,
"fish": FishComplete,
"powershell": PowerShellComplete,
"zsh": ZshComplete,
}

Expand Down
31 changes: 31 additions & 0 deletions tests/test_shell_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from click.shell_completion import add_completion_class
from click.shell_completion import CompletionItem
from click.shell_completion import FishComplete
from click.shell_completion import PowerShellComplete
from click.shell_completion import shell_complete
from click.shell_completion import ShellComplete
from click.types import Choice
Expand Down Expand Up @@ -353,6 +354,17 @@ def test_full_source(runner, shell):
assert f"_CLI_COMPLETE={shell}_complete" in result.output


def test_full_source_powershell(runner):
# The PowerShell source script sets the completion env var via
# `$env:_CLI_COMPLETE = 'powershell_complete'`, so the substring
# `_CLI_COMPLETE=powershell_complete` used by test_full_source above
# is not present. Assert on the PowerShell-specific markers instead.
cli = Group("cli", commands=[Command("a"), Command("b")])
result = runner.invoke(cli, env={"_CLI_COMPLETE": "powershell_source"})
assert "Register-ArgumentCompleter" in result.output
assert "powershell_complete" in result.output


@pytest.mark.parametrize(
("shell", "env", "expect"),
[
Expand All @@ -363,6 +375,12 @@ def test_full_source(runner, shell):
("fish", {"COMP_WORDS": "", "COMP_CWORD": ""}, "plain,a\nplain,b\tbee\n"),
("fish", {"COMP_WORDS": "a b", "COMP_CWORD": "b"}, "plain,b\tbee\n"),
("fish", {"COMP_WORDS": 'a "b', "COMP_CWORD": '"b'}, "plain,b\tbee\n"),
(
"powershell",
{"COMP_WORDS": "", "COMP_CWORD": "0"},
"plain\na\n_\nplain\nb\nbee\n",
),
("powershell", {"COMP_WORDS": "a b", "COMP_CWORD": "1"}, "plain\nb\nbee\n"),
],
)
@pytest.mark.usefixtures("_patch_for_completion")
Expand Down Expand Up @@ -585,3 +603,16 @@ def test_fish_format_completion_escapes_help():
# The newline is escaped to the literal characters backslash-n and the tab
# becomes a space, so each completion stays on one line for fish.
assert fc.format_completion(item) == "plain,--at\tfirst\\nsecond third"


def test_powershell_format_completion_escapes_help():
pc = PowerShellComplete(Command("x"), {}, "x", "_X_COMPLETE")
# Newlines (LF and CRLF) collapse to spaces so the help text stays
# on a single line and doesn't break the 3-lines-per-item framing
# consumed by the PowerShell completion script.
item = CompletionItem("--at", help="first\nsecond\r\nthird")
assert pc.format_completion(item) == "plain\n--at\nfirst second third"
# Items without help text use the "_" sentinel, matching the format
# produced by ZshComplete.
item = CompletionItem("--at")
assert pc.format_completion(item) == "plain\n--at\n_"
Loading