Skip to content

Commit f2b8075

Browse files
branchvSecrusJB26
authored
Various fixes to completion system
Co-authored-by: Bartek Sokorski <[email protected]> Co-authored-by: Jürn Brodersen <[email protected]>
1 parent d4ca6b1 commit f2b8075

File tree

3 files changed

+75
-21
lines changed

3 files changed

+75
-21
lines changed

news/357.bugfix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed subcommand completions for Fish.

src/cleo/commands/completions_command.py

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
from pathlib import Path
1111
from typing import TYPE_CHECKING
1212
from typing import ClassVar
13+
from typing import cast
1314

1415
from cleo import helpers
1516
from cleo._compat import shell_quote
1617
from cleo.commands.command import Command
1718
from cleo.commands.completions.templates import TEMPLATES
19+
from cleo.exceptions import CleoRuntimeError
1820

1921

2022
if TYPE_CHECKING:
@@ -138,10 +140,32 @@ def render(self, shell: str) -> str:
138140

139141
raise RuntimeError(f"Unrecognized shell: {shell}")
140142

143+
@staticmethod
144+
def _get_prog_name_from_stack() -> str:
145+
package_name = ""
146+
frame = inspect.currentframe()
147+
f_back = frame.f_back if frame is not None else None
148+
f_globals = f_back.f_globals if f_back is not None else None
149+
# break reference cycle
150+
# https://docs.python.org/3/library/inspect.html#the-interpreter-stack
151+
del frame
152+
153+
if f_globals is not None:
154+
package_name = cast(str, f_globals.get("__name__"))
155+
156+
if package_name == "__main__":
157+
package_name = cast(str, f_globals.get("__package__"))
158+
159+
if package_name:
160+
package_name = package_name.partition(".")[0]
161+
162+
if not package_name:
163+
raise CleoRuntimeError("Can not determine package name")
164+
165+
return package_name
166+
141167
def _get_script_name_and_path(self) -> tuple[str, str]:
142-
# FIXME: when generating completions via `python -m script completions`,
143-
# we incorrectly infer `script_name` as `__main__.py`
144-
script_name = self._io.input.script_name or inspect.stack()[-1][1]
168+
script_name = self._io.input.script_name or self._get_prog_name_from_stack()
145169
script_path = posixpath.realpath(script_name)
146170
script_name = Path(script_path).name
147171

@@ -250,34 +274,62 @@ def sanitize(s: str) -> str:
250274
# Commands + options
251275
cmds = []
252276
cmds_opts = []
253-
cmds_names = []
277+
namespaces = set()
254278
for cmd in sorted(self.application.all().values(), key=lambda c: c.name or ""):
255279
if cmd.hidden or not cmd.enabled or not cmd.name:
256280
continue
257-
command_name = shell_quote(cmd.name) if " " in cmd.name else cmd.name
258-
cmds.append(
259-
f"complete -c {script_name} -f -n '__fish{function}_no_subcommand' "
260-
f"-a {command_name} -d '{sanitize(cmd.description)}'"
261-
)
281+
cmd_path = cmd.name.split(" ")
282+
namespace = cmd_path[0]
283+
cmd_name = cmd_path[-1] if " " in cmd.name else cmd.name
284+
285+
# We either have a command like `poetry add` or a nested (namespaced)
286+
# command like `poetry cache clear`.
287+
if len(cmd_path) == 1:
288+
cmds.append(
289+
f"complete -c {script_name} -f -n '__fish{function}_no_subcommand' "
290+
f"-a {cmd_name} -d '{sanitize(cmd.description)}'"
291+
)
292+
condition = f"__fish_seen_subcommand_from {cmd_name}"
293+
else:
294+
# Complete the namespace first
295+
if namespace not in namespaces:
296+
cmds.append(
297+
f"complete -c {script_name} -f -n "
298+
f"'__fish{function}_no_subcommand' -a {namespace}"
299+
)
300+
# Now complete the command
301+
subcmds = [
302+
name.split(" ")[-1] for name in self.application.all(namespace)
303+
]
304+
cmds.append(
305+
f"complete -c {script_name} -f -n '__fish_seen_subcommand_from "
306+
f"{namespace}; and not __fish_seen_subcommand_from {' '.join(subcmds)}' "
307+
f"-a {cmd_name} -d '{sanitize(cmd.description)}'"
308+
)
309+
condition = (
310+
f"__fish_seen_subcommand_from {namespace}; "
311+
f"and __fish_seen_subcommand_from {cmd_name}"
312+
)
313+
262314
cmds_opts += [
263-
f"# {command_name}",
315+
f"# {cmd.name}",
264316
*[
265-
f"complete -c {script_name} -A "
266-
f"-n '__fish_seen_subcommand_from {sanitize(command_name)}' "
317+
f"complete -c {script_name} "
318+
f"-n '{condition}' "
267319
f"-l {opt.name} -d '{sanitize(opt.description)}'"
268320
for opt in sorted(cmd.definition.options, key=lambda o: o.name)
269321
],
270322
"", # newline
271323
]
272-
cmds_names.append(command_name)
324+
namespaces.add(namespace)
273325

274326
return TEMPLATES["fish"] % {
275327
"script_name": script_name,
276328
"function": function,
277329
"opts": "\n".join(opts),
278330
"cmds": "\n".join(cmds),
279331
"cmds_opts": "\n".join(cmds_opts[:-1]), # trim trailing newline
280-
"cmds_names": " ".join(cmds_names),
332+
"cmds_names": " ".join(sorted(namespaces)),
281333
}
282334

283335
def get_shell_type(self) -> str:

tests/commands/completion/fixtures/fish.txt

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
function __fish_my_function_no_subcommand
22
for i in (commandline -opc)
3-
if contains -- $i command:with:colons hello help list 'spaced command'
3+
if contains -- $i command:with:colons hello help list spaced
44
return 1
55
end
66
end
@@ -21,20 +21,21 @@ complete -c script -f -n '__fish_my_function_no_subcommand' -a command:with:colo
2121
complete -c script -f -n '__fish_my_function_no_subcommand' -a hello -d 'Complete me please.'
2222
complete -c script -f -n '__fish_my_function_no_subcommand' -a help -d 'Displays help for a command.'
2323
complete -c script -f -n '__fish_my_function_no_subcommand' -a list -d 'Lists commands.'
24-
complete -c script -f -n '__fish_my_function_no_subcommand' -a 'spaced command' -d 'Command with space in name.'
24+
complete -c script -f -n '__fish_my_function_no_subcommand' -a spaced
25+
complete -c script -f -n '__fish_seen_subcommand_from spaced; and not __fish_seen_subcommand_from command' -a command -d 'Command with space in name.'
2526

2627
# command options
2728

2829
# command:with:colons
29-
complete -c script -A -n '__fish_seen_subcommand_from command:with:colons' -l goodbye -d ''
30+
complete -c script -n '__fish_seen_subcommand_from command:with:colons' -l goodbye -d ''
3031

3132
# hello
32-
complete -c script -A -n '__fish_seen_subcommand_from hello' -l dangerous-option -d 'This $hould be `escaped`.'
33-
complete -c script -A -n '__fish_seen_subcommand_from hello' -l option-without-description -d ''
33+
complete -c script -n '__fish_seen_subcommand_from hello' -l dangerous-option -d 'This $hould be `escaped`.'
34+
complete -c script -n '__fish_seen_subcommand_from hello' -l option-without-description -d ''
3435

3536
# help
3637

3738
# list
3839

39-
# 'spaced command'
40-
complete -c script -A -n '__fish_seen_subcommand_from \'spaced command\'' -l goodbye -d ''
40+
# spaced command
41+
complete -c script -n '__fish_seen_subcommand_from spaced; and __fish_seen_subcommand_from command' -l goodbye -d ''

0 commit comments

Comments
 (0)