From 7bfb608c8a41b156888e09e5edcd7557fe3b939c Mon Sep 17 00:00:00 2001 From: Branch Vincent Date: Mon, 21 Aug 2023 00:09:23 -0700 Subject: [PATCH 1/3] fix: properly complete subcommands for fish --- news/357.bugfix.md | 1 + src/cleo/commands/completions_command.py | 48 ++++++++++++++++----- tests/commands/completion/fixtures/fish.txt | 9 ++-- 3 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 news/357.bugfix.md diff --git a/news/357.bugfix.md b/news/357.bugfix.md new file mode 100644 index 00000000..3efdd799 --- /dev/null +++ b/news/357.bugfix.md @@ -0,0 +1 @@ +Fixed subcommand completions for Fish. diff --git a/src/cleo/commands/completions_command.py b/src/cleo/commands/completions_command.py index b7d682ff..3f913aeb 100644 --- a/src/cleo/commands/completions_command.py +++ b/src/cleo/commands/completions_command.py @@ -250,26 +250,54 @@ def sanitize(s: str) -> str: # Commands + options cmds = [] cmds_opts = [] - cmds_names = [] + namespaces = set() for cmd in sorted(self.application.all().values(), key=lambda c: c.name or ""): if cmd.hidden or not cmd.enabled or not cmd.name: continue - command_name = shell_quote(cmd.name) if " " in cmd.name else cmd.name - cmds.append( - f"complete -c {script_name} -f -n '__fish{function}_no_subcommand' " - f"-a {command_name} -d '{sanitize(cmd.description)}'" - ) + cmd_path = cmd.name.split(" ") + namespace = cmd_path[0] + cmd_name = cmd_path[-1] if " " in cmd.name else cmd.name + + # We either have a command like `poetry add` or a nested (namespaced) + # command like `poetry cache clear`. + if len(cmd_path) == 1: + cmds.append( + f"complete -c {script_name} -f -n '__fish{function}_no_subcommand' " + f"-a {cmd_name} -d '{sanitize(cmd.description)}'" + ) + condition = f"__fish_seen_subcommand_from {cmd_name}" + else: + # Complete the namespace first + if namespace not in namespaces: + cmds.append( + f"complete -c {script_name} -f -n " + f"'__fish{function}_no_subcommand' -a {namespace}" + ) + # Now complete the command + subcmds = [ + name.split(" ")[-1] for name in self.application.all(namespace) + ] + cmds.append( + f"complete -c {script_name} -f -n '__fish_seen_subcommand_from " + f"{namespace}; and not __fish_seen_subcommand_from {' '.join(subcmds)}' " # noqa: E501 + f"-a {cmd_name} -d '{sanitize(cmd.description)}'" + ) + condition = ( + f"__fish_seen_subcommand_from {namespace}; " + f"and __fish_seen_subcommand_from {cmd_name}" + ) + cmds_opts += [ - f"# {command_name}", + f"# {cmd.name}", *[ f"complete -c {script_name} -A " - f"-n '__fish_seen_subcommand_from {sanitize(command_name)}' " + f"-n '{condition}' " f"-l {opt.name} -d '{sanitize(opt.description)}'" for opt in sorted(cmd.definition.options, key=lambda o: o.name) ], "", # newline ] - cmds_names.append(command_name) + namespaces.add(namespace) return TEMPLATES["fish"] % { "script_name": script_name, @@ -277,7 +305,7 @@ def sanitize(s: str) -> str: "opts": "\n".join(opts), "cmds": "\n".join(cmds), "cmds_opts": "\n".join(cmds_opts[:-1]), # trim trailing newline - "cmds_names": " ".join(cmds_names), + "cmds_names": " ".join(sorted(namespaces)), } def get_shell_type(self) -> str: diff --git a/tests/commands/completion/fixtures/fish.txt b/tests/commands/completion/fixtures/fish.txt index e47611d3..07a991bc 100644 --- a/tests/commands/completion/fixtures/fish.txt +++ b/tests/commands/completion/fixtures/fish.txt @@ -1,6 +1,6 @@ function __fish_my_function_no_subcommand for i in (commandline -opc) - if contains -- $i command:with:colons hello help list 'spaced command' + if contains -- $i command:with:colons hello help list spaced return 1 end end @@ -21,7 +21,8 @@ complete -c script -f -n '__fish_my_function_no_subcommand' -a command:with:colo complete -c script -f -n '__fish_my_function_no_subcommand' -a hello -d 'Complete me please.' complete -c script -f -n '__fish_my_function_no_subcommand' -a help -d 'Displays help for a command.' complete -c script -f -n '__fish_my_function_no_subcommand' -a list -d 'Lists commands.' -complete -c script -f -n '__fish_my_function_no_subcommand' -a 'spaced command' -d 'Command with space in name.' +complete -c script -f -n '__fish_my_function_no_subcommand' -a spaced +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.' # command options @@ -36,5 +37,5 @@ complete -c script -A -n '__fish_seen_subcommand_from hello' -l option-without-d # list -# 'spaced command' -complete -c script -A -n '__fish_seen_subcommand_from \'spaced command\'' -l goodbye -d '' +# spaced command +complete -c script -A -n '__fish_seen_subcommand_from spaced; and __fish_seen_subcommand_from command' -l goodbye -d '' From 12ee7a32e71c20524b6006e3e16454020be7b7eb Mon Sep 17 00:00:00 2001 From: Bartek Sokorski Date: Wed, 25 Oct 2023 15:25:07 +0200 Subject: [PATCH 2/3] Removed deprecated `-A` option from fish completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jürn Brodersen --- src/cleo/commands/completions_command.py | 2 +- tests/commands/completion/fixtures/fish.txt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cleo/commands/completions_command.py b/src/cleo/commands/completions_command.py index 3f913aeb..85853cbf 100644 --- a/src/cleo/commands/completions_command.py +++ b/src/cleo/commands/completions_command.py @@ -290,7 +290,7 @@ def sanitize(s: str) -> str: cmds_opts += [ f"# {cmd.name}", *[ - f"complete -c {script_name} -A " + f"complete -c {script_name} " f"-n '{condition}' " f"-l {opt.name} -d '{sanitize(opt.description)}'" for opt in sorted(cmd.definition.options, key=lambda o: o.name) diff --git a/tests/commands/completion/fixtures/fish.txt b/tests/commands/completion/fixtures/fish.txt index 07a991bc..eb7cf3bd 100644 --- a/tests/commands/completion/fixtures/fish.txt +++ b/tests/commands/completion/fixtures/fish.txt @@ -27,15 +27,15 @@ complete -c script -f -n '__fish_seen_subcommand_from spaced; and not __fish_see # command options # command:with:colons -complete -c script -A -n '__fish_seen_subcommand_from command:with:colons' -l goodbye -d '' +complete -c script -n '__fish_seen_subcommand_from command:with:colons' -l goodbye -d '' # hello -complete -c script -A -n '__fish_seen_subcommand_from hello' -l dangerous-option -d 'This $hould be `escaped`.' -complete -c script -A -n '__fish_seen_subcommand_from hello' -l option-without-description -d '' +complete -c script -n '__fish_seen_subcommand_from hello' -l dangerous-option -d 'This $hould be `escaped`.' +complete -c script -n '__fish_seen_subcommand_from hello' -l option-without-description -d '' # help # list # spaced command -complete -c script -A -n '__fish_seen_subcommand_from spaced; and __fish_seen_subcommand_from command' -l goodbye -d '' +complete -c script -n '__fish_seen_subcommand_from spaced; and __fish_seen_subcommand_from command' -l goodbye -d '' From 805e1abb48e4d1754dc9adc2dfeeb5f3921c9320 Mon Sep 17 00:00:00 2001 From: Bartek Sokorski Date: Wed, 25 Oct 2023 15:31:38 +0200 Subject: [PATCH 3/3] Fix script name detection --- src/cleo/commands/completions_command.py | 32 +++++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/cleo/commands/completions_command.py b/src/cleo/commands/completions_command.py index 85853cbf..11735cde 100644 --- a/src/cleo/commands/completions_command.py +++ b/src/cleo/commands/completions_command.py @@ -10,11 +10,13 @@ from pathlib import Path from typing import TYPE_CHECKING from typing import ClassVar +from typing import cast from cleo import helpers from cleo._compat import shell_quote from cleo.commands.command import Command from cleo.commands.completions.templates import TEMPLATES +from cleo.exceptions import CleoRuntimeError if TYPE_CHECKING: @@ -138,10 +140,32 @@ def render(self, shell: str) -> str: raise RuntimeError(f"Unrecognized shell: {shell}") + @staticmethod + def _get_prog_name_from_stack() -> str: + package_name = "" + frame = inspect.currentframe() + f_back = frame.f_back if frame is not None else None + f_globals = f_back.f_globals if f_back is not None else None + # break reference cycle + # https://docs.python.org/3/library/inspect.html#the-interpreter-stack + del frame + + if f_globals is not None: + package_name = cast(str, f_globals.get("__name__")) + + if package_name == "__main__": + package_name = cast(str, f_globals.get("__package__")) + + if package_name: + package_name = package_name.partition(".")[0] + + if not package_name: + raise CleoRuntimeError("Can not determine package name") + + return package_name + def _get_script_name_and_path(self) -> tuple[str, str]: - # FIXME: when generating completions via `python -m script completions`, - # we incorrectly infer `script_name` as `__main__.py` - script_name = self._io.input.script_name or inspect.stack()[-1][1] + script_name = self._io.input.script_name or self._get_prog_name_from_stack() script_path = posixpath.realpath(script_name) script_name = Path(script_path).name @@ -279,7 +303,7 @@ def sanitize(s: str) -> str: ] cmds.append( f"complete -c {script_name} -f -n '__fish_seen_subcommand_from " - f"{namespace}; and not __fish_seen_subcommand_from {' '.join(subcmds)}' " # noqa: E501 + f"{namespace}; and not __fish_seen_subcommand_from {' '.join(subcmds)}' " f"-a {cmd_name} -d '{sanitize(cmd.description)}'" ) condition = (