From 7dbeea20de2bb3c779061806117fbcd1740733b7 Mon Sep 17 00:00:00 2001 From: Tin Lai Date: Wed, 24 Jul 2024 00:49:35 +1000 Subject: [PATCH 1/9] supports fish shell Signed-off-by: Tin Lai --- shtab/__init__.py | 195 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 193 insertions(+), 2 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 457bd2f..4e84368 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -38,8 +38,8 @@ SUPPORTED_SHELLS: List[str] = [] _SUPPORTED_COMPLETERS = {} CHOICE_FUNCTIONS: Dict[str, Dict[str, str]] = { - "file": {"bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f"}, - "directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d"}} + "file": {"bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f", "fish": None}, + "directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d", "fish": None}} FILE = CHOICE_FUNCTIONS["file"] DIRECTORY = DIR = CHOICE_FUNCTIONS["directory"] FLAG_OPTION = ( @@ -784,6 +784,197 @@ def recurse_parser(cparser, positional_idx, requirements=None): optionals_special_str=' \\\n '.join(specials)) +def get_fish_commands(root_parser, choice_functions=None): + """ + Recursive subcommand parser traversal, returning lists of information on + commands (formatted for output to the completions script). + printing fish syntax. + """ + choice_type2fn = {k: v["fish"] for k, v in CHOICE_FUNCTIONS.items()} + if choice_functions: + choice_type2fn.update(choice_functions) + + def get_option_strings(parser): + """Flattened list of all `parser`'s option strings.""" + res = [] + for opt in parser._get_optional_actions(): + line = [] + if opt.help == SUPPRESS: + continue + short_opt, long_opt = None, None + for o in opt.option_strings: + if o.startswith("--"): + long_opt = o[2:] + elif o.startswith("-") and len(o) == 2: + short_opt = o[1:] + if short_opt: + line.extend(["-s", short_opt]) + if long_opt: + line.extend(["-l", long_opt]) + if opt.help: + line.extend(["-d", opt.help]) + + complete_args = None + if opt.choices: + complete_args = [] + for c in opt.choices: + complete_args.append(c) + complete_args.append(opt.dest) + res.append((line, complete_args)) + return res + + option_strings = [] + choices = [] + + def recurse(parser, using_cmd=()): + """recurse through subparsers, appending to the return lists""" + + def _escape_and_quote_if_needed(word, quote="'", escape_cmd=False): + word = word.replace("'", r"\'") # escape + if escape_cmd: + word = word.replace("(", r"\(") # escape + word = word.replace(")", r"\)") # escape + if " " in word and not (word[0] in ('"', "'") and word[-1] in ('"', "'")): + word = f"{quote}{word}{quote}" + return word + + def format_complete_command( + args=None, + complete_args=None, + complete_args_with_exclusive=True, + desc=None, + ): + complete_command = ["complete", "-c", root_parser.prog] + if using_cmd: + complete_command.extend( + [ + "-n", + f"__fish_seen_subcommand_from {' '.join(using_cmd)}", + ] + ) + else: + complete_command.extend(["-n", f"__fish_use_subcommand"]) + if args: + complete_command.extend(args) + if desc: + complete_command.extend(["-d", "command" if desc == SUPPRESS else desc]) + # add quote if needed + complete_command = list(map(_escape_and_quote_if_needed, complete_command)) + # the following should not be quoted as a whole + # (printf - -"%s\n" "commands configs repo switches") + if complete_args: + complete_command.extend( + [ + "-a", + r'{q}(printf "%s\t%s\n" {args}){q}'.format( + q="'", + args=" ".join( + _escape_and_quote_if_needed( + a, quote='"', escape_cmd=True + ) + for a in complete_args + ), + ), + ] + ) + if complete_args_with_exclusive: + complete_command.append("-x") + return " ".join(complete_command) + + # positional arguments + discovered_subparsers = [] + for positional in parser._get_positional_actions(): + if positional.help == SUPPRESS: + continue + + if positional.choices: + # map choice of action to their help msg + choices_to_action = { + v.dest: v.help for v in positional._choices_actions + } + + this_positional_choices = [] + for choice in positional.choices: + + if isinstance(choice, Choice): + # not supported + pass + elif isinstance(positional.choices, dict): + # subparser, so append to list of subparsers & recurse + log.debug("subcommand:%s", choice) + public_cmds = get_public_subcommands(positional) + if choice in public_cmds: + # ic(choice) + discovered_subparsers.append(str(choice)) + this_positional_choices.extend( + (choice, choices_to_action.get(choice, "")) + ) + recurse( + positional.choices[choice], + using_cmd=using_cmd + (choice,), + ) + else: + log.debug("skip:subcommand:%s", choice) + else: + # simple choice + this_positional_choices.extend( + (choice, choices_to_action.get(choice, "")) + ) + + if this_positional_choices: + choices.append( + format_complete_command( + complete_args=this_positional_choices, + # desc=positional.dest, + ) + ) + + # optional arguments + option_strings.extend( + [ + format_complete_command(ret[0], complete_args=ret[1]) + for ret in get_option_strings(parser) + ] + ) + + recurse(root_parser) + return option_strings, choices + + +@mark_completer("fish") +def complete_fish(parser, root_prefix=None, preamble="", choice_functions=None): + """ + Returns fish syntax autocompletion script. + + See `complete` for arguments. + """ + + option_strings, choices = get_fish_commands( + parser, choice_functions=choice_functions + ) + + return Template( + """\ +# AUTOMATICALLY GENERATED by `shtab` + +${option_strings} + +${choices}\ +\ +${preamble} +""" + ).safe_substitute( + option_strings="\n".join(option_strings), + choices="\n".join(choices), + preamble=( + "\n# Custom Preamble\n" + preamble + "\n# End Custom Preamble\n" + if preamble + else "" + ), + prog=parser.prog, + ) + + def complete(parser: ArgumentParser, shell: str = "bash", root_prefix: Opt[str] = None, preamble: Union[str, Dict[str, str]] = "", choice_functions: Opt[Any] = None) -> str: """ From e77eab28f04b0b9e7455f3588d619069a0fdf8df Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 14:53:52 +0000 Subject: [PATCH 2/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- shtab/__init__.py | 83 ++++++++++++++++------------------------------- 1 file changed, 28 insertions(+), 55 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 4e84368..2dfce15 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -828,12 +828,11 @@ def get_option_strings(parser): def recurse(parser, using_cmd=()): """recurse through subparsers, appending to the return lists""" - def _escape_and_quote_if_needed(word, quote="'", escape_cmd=False): - word = word.replace("'", r"\'") # escape + word = word.replace("'", r"\'") # escape if escape_cmd: - word = word.replace("(", r"\(") # escape - word = word.replace(")", r"\)") # escape + word = word.replace("(", r"\(") # escape + word = word.replace(")", r"\)") # escape if " " in word and not (word[0] in ('"', "'") and word[-1] in ('"', "'")): word = f"{quote}{word}{quote}" return word @@ -846,12 +845,9 @@ def format_complete_command( ): complete_command = ["complete", "-c", root_parser.prog] if using_cmd: - complete_command.extend( - [ - "-n", - f"__fish_seen_subcommand_from {' '.join(using_cmd)}", - ] - ) + complete_command.extend([ + "-n", + f"__fish_seen_subcommand_from {' '.join(using_cmd)}",]) else: complete_command.extend(["-n", f"__fish_use_subcommand"]) if args: @@ -863,20 +859,14 @@ def format_complete_command( # the following should not be quoted as a whole # (printf - -"%s\n" "commands configs repo switches") if complete_args: - complete_command.extend( - [ - "-a", - r'{q}(printf "%s\t%s\n" {args}){q}'.format( - q="'", - args=" ".join( - _escape_and_quote_if_needed( - a, quote='"', escape_cmd=True - ) - for a in complete_args - ), - ), - ] - ) + complete_command.extend([ + "-a", + r'{q}(printf "%s\t%s\n" {args}){q}'.format( + q="'", + args=" ".join( + _escape_and_quote_if_needed(a, quote='"', escape_cmd=True) + for a in complete_args), + ),]) if complete_args_with_exclusive: complete_command.append("-x") return " ".join(complete_command) @@ -889,9 +879,7 @@ def format_complete_command( if positional.choices: # map choice of action to their help msg - choices_to_action = { - v.dest: v.help for v in positional._choices_actions - } + choices_to_action = {v.dest: v.help for v in positional._choices_actions} this_positional_choices = [] for choice in positional.choices: @@ -907,8 +895,7 @@ def format_complete_command( # ic(choice) discovered_subparsers.append(str(choice)) this_positional_choices.extend( - (choice, choices_to_action.get(choice, "")) - ) + (choice, choices_to_action.get(choice, ""))) recurse( positional.choices[choice], using_cmd=using_cmd + (choice,), @@ -917,25 +904,18 @@ def format_complete_command( log.debug("skip:subcommand:%s", choice) else: # simple choice - this_positional_choices.extend( - (choice, choices_to_action.get(choice, "")) - ) + this_positional_choices.extend((choice, choices_to_action.get(choice, ""))) if this_positional_choices: choices.append( - format_complete_command( - complete_args=this_positional_choices, - # desc=positional.dest, - ) - ) + format_complete_command(complete_args=this_positional_choices, + # desc=positional.dest, + )) # optional arguments - option_strings.extend( - [ - format_complete_command(ret[0], complete_args=ret[1]) - for ret in get_option_strings(parser) - ] - ) + option_strings.extend([ + format_complete_command(ret[0], complete_args=ret[1]) + for ret in get_option_strings(parser)]) recurse(root_parser) return option_strings, choices @@ -949,12 +929,9 @@ def complete_fish(parser, root_prefix=None, preamble="", choice_functions=None): See `complete` for arguments. """ - option_strings, choices = get_fish_commands( - parser, choice_functions=choice_functions - ) + option_strings, choices = get_fish_commands(parser, choice_functions=choice_functions) - return Template( - """\ + return Template("""\ # AUTOMATICALLY GENERATED by `shtab` ${option_strings} @@ -962,15 +939,11 @@ def complete_fish(parser, root_prefix=None, preamble="", choice_functions=None): ${choices}\ \ ${preamble} -""" - ).safe_substitute( +""").safe_substitute( option_strings="\n".join(option_strings), choices="\n".join(choices), - preamble=( - "\n# Custom Preamble\n" + preamble + "\n# End Custom Preamble\n" - if preamble - else "" - ), + preamble=("\n# Custom Preamble\n" + preamble + + "\n# End Custom Preamble\n" if preamble else ""), prog=parser.prog, ) From aec27de31d41f4db1e6c3a656a1abe51a9d5d420 Mon Sep 17 00:00:00 2001 From: Tin Lai Date: Wed, 24 Jul 2024 21:56:07 +1000 Subject: [PATCH 3/9] fix tests Signed-off-by: Tin Lai --- shtab/__init__.py | 2 +- tests/test_shtab.py | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 4e84368..1dbf412 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -890,7 +890,7 @@ def format_complete_command( if positional.choices: # map choice of action to their help msg choices_to_action = { - v.dest: v.help for v in positional._choices_actions + v.dest: v.help for v in getattr(positional, "_choices_actions", []) } this_positional_choices = [] diff --git a/tests/test_shtab.py b/tests/test_shtab.py index 05ce08c..f44a078 100644 --- a/tests/test_shtab.py +++ b/tests/test_shtab.py @@ -75,7 +75,7 @@ def test_main_self_completion(shell, caplog, capsys): assert not captured.err expected = { "bash": "complete -o filenames -F _shtab_shtab shtab", "zsh": "_shtab_shtab_commands()", - "tcsh": "complete shtab"} + "tcsh": "complete shtab", "fish": "complete -c shtab"} assert expected[shell] in captured.out assert not caplog.record_tuples @@ -111,6 +111,18 @@ def test_prog_scripts(shell, caplog, capsys): "compdef _shtab_shtab -N script.py"] elif shell == "tcsh": assert script_py == ["complete script.py \\"] + elif shell == "fish": + assert script_py == [ + "complete -c script.py -n __fish_use_subcommand -s h -l help -d 'show this help message and exit'", + "complete -c script.py -n __fish_use_subcommand -l version -d 'show program\\'s version number and exit'", + "complete -c script.py -n __fish_use_subcommand -s s -l shell -a '(printf \"%s\\t%s\\n\" bash shell zsh shell tcsh shell fish shell)' -x", + "complete -c script.py -n __fish_use_subcommand -l prefix -d 'prepended to generated functions to avoid clashes'", + "complete -c script.py -n __fish_use_subcommand -l preamble -d 'prepended to generated script'", + "complete -c script.py -n __fish_use_subcommand -l prog -d 'custom program name (overrides `parser.prog`)'", + "complete -c script.py -n __fish_use_subcommand -s u -l error-unimportable -d 'raise errors if `parser` is not found in $PYTHONPATH'", + "complete -c script.py -n __fish_use_subcommand -l verbose -d 'Log debug information'", + "complete -c script.py -n __fish_use_subcommand -l print-own-completion -d 'print shtab\\'s own completion' -a '(printf \"%s\\t%s\\n\" bash print_own_completion zsh print_own_completion tcsh print_own_completion fish print_own_completion)' -x", + ] else: raise NotImplementedError(shell) From 09ded71d302cd0c5b9046185e15bbd2bb7fa4a74 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jul 2024 11:57:57 +0000 Subject: [PATCH 4/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- shtab/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 47d7322..14fbb8f 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -879,7 +879,9 @@ def format_complete_command( if positional.choices: # map choice of action to their help msg - choices_to_action = {v.dest: v.help for v in getattr(positional, "_choices_actions", [])} + choices_to_action = { + v.dest: v.help + for v in getattr(positional, "_choices_actions", [])} this_positional_choices = [] for choice in positional.choices: From 8417c22693ae92c918f6add4ded9519fc8f82af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saugat=20Pachhai=20=28=E0=A4=B8=E0=A5=8C=E0=A4=97=E0=A4=BE?= =?UTF-8?q?=E0=A4=A4=29?= Date: Mon, 6 Oct 2025 19:24:37 +0545 Subject: [PATCH 5/9] fix fish completion generator to work properly with nested commands and choices --- shtab/__init__.py | 128 +++++++++++++++++++++++++++++++++++++------- tests/test_shtab.py | 46 +++++++++++----- 2 files changed, 141 insertions(+), 33 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 14fbb8f..e15d844 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -12,11 +12,12 @@ _CountAction, _HelpAction, _StoreConstAction, + _SubParsersAction, _VersionAction, ) from collections import defaultdict from functools import total_ordering -from itertools import starmap +from itertools import starmap, zip_longest from string import Template from typing import Any, Dict, List from typing import Optional as Opt @@ -38,8 +39,11 @@ SUPPORTED_SHELLS: List[str] = [] _SUPPORTED_COMPLETERS = {} CHOICE_FUNCTIONS: Dict[str, Dict[str, str]] = { - "file": {"bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f", "fish": None}, - "directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d", "fish": None}} + "file": { + "bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f", + "fish": "__fish_complete_path"}, "directory": { + "bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d", + "fish": "__fish_complete_directories"}} FILE = CHOICE_FUNCTIONS["file"] DIRECTORY = DIR = CHOICE_FUNCTIONS["directory"] FLAG_OPTION = ( @@ -784,6 +788,13 @@ def recurse_parser(cparser, positional_idx, requirements=None): optionals_special_str=' \\\n '.join(specials)) +def get_public_subcommands_from_parser(parser: ArgumentParser): + for action in parser._actions: + if isinstance(action, _SubParsersAction): + return get_public_subcommands(action) + return [] + + def get_fish_commands(root_parser, choice_functions=None): """ Recursive subcommand parser traversal, returning lists of information on @@ -815,17 +826,23 @@ def get_option_strings(parser): line.extend(["-d", opt.help]) complete_args = None - if opt.choices: + comp_pattern = None + if hasattr(opt, "complete"): + # shtab `.complete = ...` functions + comp_pattern = complete2pattern(opt.complete, "fish", choice_type2fn) + elif opt.choices: complete_args = [] for c in opt.choices: complete_args.append(c) complete_args.append(opt.dest) - res.append((line, complete_args)) + res.append((line, complete_args, comp_pattern)) return res option_strings = [] choices = [] + eprog = wordify(root_parser.prog) + def recurse(parser, using_cmd=()): """recurse through subparsers, appending to the return lists""" def _escape_and_quote_if_needed(word, quote="'", escape_cmd=False): @@ -841,15 +858,31 @@ def format_complete_command( args=None, complete_args=None, complete_args_with_exclusive=True, + complete_func=None, desc=None, ): + # see: https://github.com/clap-rs/clap/pull/5568 + needs_fn_name = f"__fish_{eprog}_needs_command" + using_fn_name = f"__fish_{eprog}_using_subcommand" + # preserve order of subcommands + subcommands = sorted(get_public_subcommands_from_parser(parser)) complete_command = ["complete", "-c", root_parser.prog] if using_cmd: - complete_command.extend([ - "-n", - f"__fish_seen_subcommand_from {' '.join(using_cmd)}",]) + out = using_fn_name + if len(using_cmd) == 1: + out += " " + using_cmd[0] + if subcommands: + out += "; and not __fish_seen_subcommand_from " + " ".join(subcommands) + elif len(using_cmd) == 2: + cmd, subcmd = using_cmd + out += f" {cmd}; and __fish_seen_subcommand_from {subcmd}" + else: + # avoid completing flags and subcommands after 3 levels + # eg: `rustup toolchain help install` + return + complete_command.extend(["-n", out]) else: - complete_command.extend(["-n", f"__fish_use_subcommand"]) + complete_command.extend(["-n", needs_fn_name]) if args: complete_command.extend(args) if desc: @@ -858,14 +891,16 @@ def format_complete_command( complete_command = list(map(_escape_and_quote_if_needed, complete_command)) # the following should not be quoted as a whole # (printf - -"%s\n" "commands configs repo switches") - if complete_args: + if complete_func: + complete_command.extend(["-a", f'"({complete_func})"', "-fr"]) + elif complete_args: complete_command.extend([ "-a", r'{q}(printf "%s\t%s\n" {args}){q}'.format( q="'", args=" ".join( - _escape_and_quote_if_needed(a, quote='"', escape_cmd=True) - for a in complete_args), + _escape_and_quote_if_needed(a, quote='"', escape_cmd=True + ) if a else '""' for a in complete_args), ),]) if complete_args_with_exclusive: complete_command.append("-x") @@ -877,6 +912,12 @@ def format_complete_command( if positional.help == SUPPRESS: continue + if hasattr(positional, "complete"): + # shtab `.complete = ...` functions + comp_pattern = complete2pattern(positional.complete, "fish", choice_type2fn) + option_strings.append( + format_complete_command(desc=positional.help, complete_func=comp_pattern)) + if positional.choices: # map choice of action to their help msg choices_to_action = { @@ -885,7 +926,6 @@ def format_complete_command( this_positional_choices = [] for choice in positional.choices: - if isinstance(choice, Choice): # not supported pass @@ -910,17 +950,35 @@ def format_complete_command( if this_positional_choices: choices.append( - format_complete_command(complete_args=this_positional_choices, - # desc=positional.dest, - )) + format_complete_command( + complete_args=this_positional_choices, # desc=positional.dest, + )) # optional arguments option_strings.extend([ - format_complete_command(ret[0], complete_args=ret[1]) + format_complete_command(ret[0], complete_args=ret[1], complete_func=ret[2]) for ret in get_option_strings(parser)]) recurse(root_parser) - return option_strings, choices + + global_options = [] + for opt in root_parser._get_optional_actions(): + long_opts, short_opts = [], [] + for o in opt.option_strings: + if o.startswith("--"): + long_opts.append(o[2:]) + elif o.startswith("-") and len(o) == 2: + short_opts.append(o[1:]) + + for _opts in zip_longest(short_opts, long_opts): + opts = tuple(filter(None, _opts)) + global_options.append((opts, opt.nargs != 0)) + + return ( + list(filter(None, option_strings)), + list(filter(None, choices)), + global_options, + ) @mark_completer("fish") @@ -931,15 +989,42 @@ def complete_fish(parser, root_prefix=None, preamble="", choice_functions=None): See `complete` for arguments. """ - option_strings, choices = get_fish_commands(parser, choice_functions=choice_functions) + option_strings, choices, global_options = get_fish_commands(parser, + choice_functions=choice_functions) return Template("""\ # AUTOMATICALLY GENERATED by `shtab` +# Print an optspec for argparse to handle cmd's options that are independent of any subcommand. +function __fish_${eprog}_global_optspecs + string join \\n ${global_options} +end + +function __fish_${eprog}_needs_command + # Figure out if the current invocation already has a command. + set -l cmd (commandline -opc) + set -e cmd[1] + argparse -s (__fish_${eprog}_global_optspecs) -- $cmd 2>/dev/null + or return + if set -q argv[1] + # Also print the command, so this can be used to figure out what it is. + echo $argv[1] + return 1 + end + return 0 +end + +function __fish_${eprog}_using_subcommand + set -l cmd (__fish_${eprog}_needs_command) + test -z "$cmd" + and return 1 + contains -- $cmd[1] $argv +end + ${option_strings} ${choices}\ -\ + ${preamble} """).safe_substitute( option_strings="\n".join(option_strings), @@ -947,6 +1032,9 @@ def complete_fish(parser, root_prefix=None, preamble="", choice_functions=None): preamble=("\n# Custom Preamble\n" + preamble + "\n# End Custom Preamble\n" if preamble else ""), prog=parser.prog, + eprog=wordify(parser.prog), + global_options=" ".join("/".join(opt) + ("=" if takes_values else "") + for opt, takes_values in global_options), ) diff --git a/tests/test_shtab.py b/tests/test_shtab.py index f44a078..0dce18a 100644 --- a/tests/test_shtab.py +++ b/tests/test_shtab.py @@ -106,23 +106,43 @@ def test_prog_scripts(shell, caplog, capsys): assert script_py == ["complete -o filenames -F _shtab_shtab script.py"] elif shell == "zsh": assert script_py == [ - "#compdef script.py", "_describe 'script.py commands' _commands", - "_shtab_shtab_options+=(': :_shtab_shtab_commands' '*::: :->script.py')", "script.py)", - "compdef _shtab_shtab -N script.py"] + "#compdef script.py", + "_describe 'script.py commands' _commands", + "_shtab_shtab_options+=(': :_shtab_shtab_commands' '*::: :->script.py')", + "script.py)", + "compdef _shtab_shtab -N script.py",] elif shell == "tcsh": assert script_py == ["complete script.py \\"] elif shell == "fish": assert script_py == [ - "complete -c script.py -n __fish_use_subcommand -s h -l help -d 'show this help message and exit'", - "complete -c script.py -n __fish_use_subcommand -l version -d 'show program\\'s version number and exit'", - "complete -c script.py -n __fish_use_subcommand -s s -l shell -a '(printf \"%s\\t%s\\n\" bash shell zsh shell tcsh shell fish shell)' -x", - "complete -c script.py -n __fish_use_subcommand -l prefix -d 'prepended to generated functions to avoid clashes'", - "complete -c script.py -n __fish_use_subcommand -l preamble -d 'prepended to generated script'", - "complete -c script.py -n __fish_use_subcommand -l prog -d 'custom program name (overrides `parser.prog`)'", - "complete -c script.py -n __fish_use_subcommand -s u -l error-unimportable -d 'raise errors if `parser` is not found in $PYTHONPATH'", - "complete -c script.py -n __fish_use_subcommand -l verbose -d 'Log debug information'", - "complete -c script.py -n __fish_use_subcommand -l print-own-completion -d 'print shtab\\'s own completion' -a '(printf \"%s\\t%s\\n\" bash print_own_completion zsh print_own_completion tcsh print_own_completion fish print_own_completion)' -x", - ] + """\ +complete -c script.py -n __fish_script_py_needs_command -s h -l help \ +-d 'show this help message and exit'""", + """\ +complete -c script.py -n __fish_script_py_needs_command -l version \ +-d 'show program\\'s version number and exit'""", + """\ +complete -c script.py -n __fish_script_py_needs_command -s s -l shell \ +-a '(printf \"%s\\t%s\\n\" bash shell zsh shell tcsh shell fish shell)' -x""", + """\ +complete -c script.py -n __fish_script_py_needs_command -l prefix \ +-d 'prepended to generated functions to avoid clashes'""", + """\ +complete -c script.py -n __fish_script_py_needs_command -l preamble \ +-d 'prepended to generated script'""", + """\ +complete -c script.py -n __fish_script_py_needs_command -l prog \ +-d 'custom program name (overrides `parser.prog`)'""", + """\ +complete -c script.py -n __fish_script_py_needs_command -s u -l error-unimportable \ +-d 'raise errors if `parser` is not found in $PYTHONPATH'""", + """\ +complete -c script.py -n __fish_script_py_needs_command -l verbose -d 'Log debug information'""", + """\ +complete -c script.py -n __fish_script_py_needs_command -l print-own-completion \ +-d 'print shtab\\'s own completion' \ +-a \'(printf "%s\\t%s\\n" bash print_own_completion zsh print_own_completion tcsh \ +print_own_completion fish print_own_completion)' -x""",] else: raise NotImplementedError(shell) From 368b8cf587f712b267166e34a9a6190d4ff179c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saugat=20Pachhai=20=28=E0=A4=B8=E0=A5=8C=E0=A4=97=E0=A4=BE?= =?UTF-8?q?=E0=A4=A4=29?= Date: Mon, 6 Oct 2025 20:11:37 +0545 Subject: [PATCH 6/9] fix help message for aliased commands --- shtab/__init__.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index e15d844..cd0fd8e 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -934,10 +934,18 @@ def format_complete_command( log.debug("subcommand:%s", choice) public_cmds = get_public_subcommands(positional) if choice in public_cmds: - # ic(choice) - discovered_subparsers.append(str(choice)) - this_positional_choices.extend( - (choice, choices_to_action.get(choice, ""))) + subparser = positional.choices[choice] + formatter = subparser._get_formatter() + backup_width = formatter._width + # large number to effectively disable wrapping + formatter._width = 1234567 + try: + desc = formatter._format_text(subparser.description or "").strip() + help = desc.split("\n")[0] + this_positional_choices.extend((choice, help)) + discovered_subparsers.append(str(choice)) + finally: + formatter._width = backup_width recurse( positional.choices[choice], using_cmd=using_cmd + (choice,), From 7bf576d3f41174f12aff180f02a0ce3c2ede8c7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saugat=20Pachhai=20=28=E0=A4=B8=E0=A5=8C=E0=A4=97=E0=A4=BE?= =?UTF-8?q?=E0=A4=A4=29?= Date: Mon, 6 Oct 2025 20:23:46 +0545 Subject: [PATCH 7/9] rollback unnecessary formatting changes --- tests/test_shtab.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_shtab.py b/tests/test_shtab.py index 0dce18a..8e844e2 100644 --- a/tests/test_shtab.py +++ b/tests/test_shtab.py @@ -106,11 +106,9 @@ def test_prog_scripts(shell, caplog, capsys): assert script_py == ["complete -o filenames -F _shtab_shtab script.py"] elif shell == "zsh": assert script_py == [ - "#compdef script.py", - "_describe 'script.py commands' _commands", - "_shtab_shtab_options+=(': :_shtab_shtab_commands' '*::: :->script.py')", - "script.py)", - "compdef _shtab_shtab -N script.py",] + "#compdef script.py", "_describe 'script.py commands' _commands", + "_shtab_shtab_options+=(': :_shtab_shtab_commands' '*::: :->script.py')", "script.py)", + "compdef _shtab_shtab -N script.py"] elif shell == "tcsh": assert script_py == ["complete script.py \\"] elif shell == "fish": From f1e714270af157cedab1075309daae9f624549b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saugat=20Pachhai=20=28=E0=A4=B8=E0=A5=8C=E0=A4=97=E0=A4=BE?= =?UTF-8?q?=E0=A4=A4=29?= Date: Tue, 7 Oct 2025 14:14:09 +0545 Subject: [PATCH 8/9] add -r only for optional items --- shtab/__init__.py | 24 ++++++++++++++---------- tests/test_shtab.py | 27 ++++++++++----------------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index cd0fd8e..6242bfe 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -39,11 +39,10 @@ SUPPORTED_SHELLS: List[str] = [] _SUPPORTED_COMPLETERS = {} CHOICE_FUNCTIONS: Dict[str, Dict[str, str]] = { - "file": { - "bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f", - "fish": "__fish_complete_path"}, "directory": { - "bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d", - "fish": "__fish_complete_directories"}} + "file": {"bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f", "fish": "-F"}, + "directory": { + "bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d", + "fish": "-f -a \"(__fish_complete_directories)\""}} FILE = CHOICE_FUNCTIONS["file"] DIRECTORY = DIR = CHOICE_FUNCTIONS["directory"] FLAG_OPTION = ( @@ -860,6 +859,7 @@ def format_complete_command( complete_args_with_exclusive=True, complete_func=None, desc=None, + positional=True, ): # see: https://github.com/clap-rs/clap/pull/5568 needs_fn_name = f"__fish_{eprog}_needs_command" @@ -891,11 +891,13 @@ def format_complete_command( complete_command = list(map(_escape_and_quote_if_needed, complete_command)) # the following should not be quoted as a whole # (printf - -"%s\n" "commands configs repo switches") + if not positional and (complete_args or complete_func): + complete_command.append("-r") if complete_func: - complete_command.extend(["-a", f'"({complete_func})"', "-fr"]) + complete_command.append(complete_func) elif complete_args: complete_command.extend([ - "-a", + "-f -a", r'{q}(printf "%s\t%s\n" {args}){q}'.format( q="'", args=" ".join( @@ -916,7 +918,8 @@ def format_complete_command( # shtab `.complete = ...` functions comp_pattern = complete2pattern(positional.complete, "fish", choice_type2fn) option_strings.append( - format_complete_command(desc=positional.help, complete_func=comp_pattern)) + format_complete_command(desc=positional.help, complete_func=comp_pattern, + positional=True)) if positional.choices: # map choice of action to their help msg @@ -960,12 +963,13 @@ def format_complete_command( choices.append( format_complete_command( complete_args=this_positional_choices, # desc=positional.dest, + positional=True, )) # optional arguments option_strings.extend([ - format_complete_command(ret[0], complete_args=ret[1], complete_func=ret[2]) - for ret in get_option_strings(parser)]) + format_complete_command(ret[0], complete_args=ret[1], complete_func=ret[2], + positional=False) for ret in get_option_strings(parser)]) recurse(root_parser) diff --git a/tests/test_shtab.py b/tests/test_shtab.py index 8e844e2..2c0e140 100644 --- a/tests/test_shtab.py +++ b/tests/test_shtab.py @@ -115,32 +115,25 @@ def test_prog_scripts(shell, caplog, capsys): assert script_py == [ """\ complete -c script.py -n __fish_script_py_needs_command -s h -l help \ --d 'show this help message and exit'""", - """\ +-d 'show this help message and exit'""", """\ complete -c script.py -n __fish_script_py_needs_command -l version \ --d 'show program\\'s version number and exit'""", - """\ -complete -c script.py -n __fish_script_py_needs_command -s s -l shell \ --a '(printf \"%s\\t%s\\n\" bash shell zsh shell tcsh shell fish shell)' -x""", - """\ +-d 'show program\\'s version number and exit'""", """\ +complete -c script.py -n __fish_script_py_needs_command -s s -l shell -r -f \ +-a '(printf \"%s\\t%s\\n\" bash shell zsh shell tcsh shell fish shell)' -x""", """\ complete -c script.py -n __fish_script_py_needs_command -l prefix \ --d 'prepended to generated functions to avoid clashes'""", - """\ +-d 'prepended to generated functions to avoid clashes'""", """\ complete -c script.py -n __fish_script_py_needs_command -l preamble \ --d 'prepended to generated script'""", - """\ +-d 'prepended to generated script'""", """\ complete -c script.py -n __fish_script_py_needs_command -l prog \ --d 'custom program name (overrides `parser.prog`)'""", - """\ +-d 'custom program name (overrides `parser.prog`)'""", """\ complete -c script.py -n __fish_script_py_needs_command -s u -l error-unimportable \ --d 'raise errors if `parser` is not found in $PYTHONPATH'""", - """\ +-d 'raise errors if `parser` is not found in $PYTHONPATH'""", """\ complete -c script.py -n __fish_script_py_needs_command -l verbose -d 'Log debug information'""", """\ complete -c script.py -n __fish_script_py_needs_command -l print-own-completion \ --d 'print shtab\\'s own completion' \ +-d 'print shtab\\'s own completion' -r -f \ -a \'(printf "%s\\t%s\\n" bash print_own_completion zsh print_own_completion tcsh \ -print_own_completion fish print_own_completion)' -x""",] +print_own_completion fish print_own_completion)' -x"""] else: raise NotImplementedError(shell) From fd87d5a8a7f4c7955c01504ed5c2d5120386b268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saugat=20Pachhai=20=28=E0=A4=B8=E0=A5=8C=E0=A4=97=E0=A4=BE?= =?UTF-8?q?=E0=A4=A4=29?= Date: Tue, 7 Oct 2025 14:33:55 +0545 Subject: [PATCH 9/9] fix preamble --- shtab/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 6242bfe..dac9f55 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -1032,13 +1032,10 @@ def complete_fish(parser, root_prefix=None, preamble="", choice_functions=None): and return 1 contains -- $cmd[1] $argv end - +${preamble} ${option_strings} -${choices}\ - -${preamble} -""").safe_substitute( +${choices}""").safe_substitute( option_strings="\n".join(option_strings), choices="\n".join(choices), preamble=("\n# Custom Preamble\n" + preamble +