Skip to content

Commit fe9b443

Browse files
author
yoshibase
committed
Fix bash completion for COMP_WORDBREAKS characters like colon
Rebuild COMP_WORDS from COMP_LINE before dispatching to Typer and strip the wordbreak prefix from candidates so values like alpine:latest complete correctly instead of returning empty or duplicating alpine:.
1 parent ac329a0 commit fe9b443

2 files changed

Lines changed: 110 additions & 2 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import os
2+
import shutil
3+
import stat
4+
import subprocess
5+
import sys
6+
import tempfile
7+
from pathlib import Path
8+
9+
from . import colon_example as mod
10+
11+
from ..utils import needs_bash
12+
13+
14+
def _bash_completion_reply(
15+
script: str,
16+
*,
17+
wrapper_dir: Path,
18+
comp_line: str,
19+
comp_words: str,
20+
comp_cword: str,
21+
) -> str:
22+
bash = shutil.which("bash")
23+
if bash is None:
24+
raise RuntimeError("bash not found")
25+
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as handle:
26+
handle.write(
27+
f"""
28+
set -euo pipefail
29+
export PATH={wrapper_dir}:{os.environ.get("PATH", "")}
30+
{script}
31+
COMP_LINE={comp_line!r}
32+
COMP_POINT=${{#COMP_LINE}}
33+
COMP_WORDS=({comp_words})
34+
COMP_CWORD={comp_cword}
35+
_colon_examplepy_completion colon_example.py
36+
printf '%s\\n' "${{COMPREPLY[@]}}"
37+
"""
38+
)
39+
path = handle.name
40+
try:
41+
result = subprocess.run(
42+
[bash, "--norc", path],
43+
capture_output=True,
44+
encoding="utf-8",
45+
env={**os.environ, "PYTHONPATH": os.pathsep.join(sys.path)},
46+
)
47+
finally:
48+
Path(path).unlink(missing_ok=True)
49+
assert result.returncode == 0, result.stderr
50+
return result.stdout
51+
52+
53+
@needs_bash
54+
def test_bash_completion_script_handles_comp_wordbreaks_colon() -> None:
55+
show = subprocess.run(
56+
[
57+
sys.executable,
58+
"-m",
59+
"coverage",
60+
"run",
61+
mod.__file__,
62+
"--show-completion",
63+
"bash",
64+
],
65+
capture_output=True,
66+
encoding="utf-8",
67+
env={
68+
**os.environ,
69+
"_TYPER_COMPLETE_TEST_DISABLE_SHELL_DETECTION": "True",
70+
},
71+
)
72+
assert show.returncode == 0
73+
assert "COMP_LINE" in show.stdout
74+
assert "COMP_WORDBREAKS" in show.stdout
75+
76+
with tempfile.TemporaryDirectory() as tmp:
77+
wrapper_dir = Path(tmp)
78+
wrapper = wrapper_dir / "colon_example.py"
79+
wrapper.write_text(
80+
f"#!/bin/sh\nexec {sys.executable} -m coverage run {mod.__file__} \"$@\"\n"
81+
)
82+
wrapper.chmod(wrapper.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
83+
84+
reply = _bash_completion_reply(
85+
show.stdout,
86+
wrapper_dir=wrapper_dir,
87+
comp_line="colon_example.py --name alpine:l",
88+
comp_words="colon_example.py --name : l",
89+
comp_cword="3",
90+
)
91+
assert "latest" in reply.splitlines()
92+
assert "alpine:latest" not in reply

typer/_completion_shared.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,26 @@ class Shells(str, Enum):
2020

2121
COMPLETION_SCRIPT_BASH = """
2222
%(complete_func)s() {
23+
local __line=${COMP_LINE:0:COMP_POINT}
24+
local -a __words
25+
read -ra __words <<< "$__line"
26+
local __cword=${#__words[@]}
27+
[[ $__line != *[[:space:]] ]] && __cword=$((__cword - 1))
28+
local __full=${__words[__cword]-}
29+
2330
local IFS=$'\n'
24-
COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\
25-
COMP_CWORD=$COMP_CWORD \\
31+
local -a __raw
32+
__raw=( $( env COMP_WORDS="${__words[*]}" \\
33+
COMP_CWORD=$__cword \\
2634
%(autocomplete_var)s=complete_bash $1 ) )
35+
36+
local __cur=${__full##*[$COMP_WORDBREAKS]}
37+
local __strip=$(( ${#__full} - ${#__cur} ))
38+
COMPREPLY=()
39+
local __c
40+
for __c in "${__raw[@]}"; do
41+
COMPREPLY+=( "${__c:__strip}" )
42+
done
2743
return 0
2844
}
2945

0 commit comments

Comments
 (0)