From e5541c8e3418f2e96efe3fe3a71a24db3e4decc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=A3o=20Afonso?= Date: Mon, 5 May 2025 17:17:52 +0100 Subject: [PATCH 1/3] Support a bash listify function Move existing code to a common function. --- shtab/__init__.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 8660d30..58561c3 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -18,7 +18,7 @@ from functools import total_ordering from itertools import starmap from string import Template -from typing import Any, Dict, List +from typing import Any, Dict, List, Sequence from typing import Optional as Opt from typing import Union @@ -128,6 +128,14 @@ def complete2pattern(opt_complete, shell: str, choice_type2fn) -> str: if isinstance(opt_complete, dict) else choice_type2fn[opt_complete]) +def bash_listify(lst: Sequence[str]) -> str: + """Create a bash array from a list of strings""" + if len(lst) == 0: + return '()' + else: + return "('%s')" % "' '".join(lst) + + def wordify(string: str) -> str: """Replace non-word chars [\\W] with underscores [_]""" return re.sub("\\W", "_", string) @@ -229,8 +237,7 @@ def recurse(parser, prefix): this_positional_choices.append(str(choice)) if this_positional_choices: - choices_str = "' '".join(this_positional_choices) - choices.append(f"{prefix}_pos_{i}_choices=('{choices_str}')") + choices.append(f"{prefix}_pos_{i}_choices={bash_listify(this_positional_choices)}") # skip default `nargs` values if positional.nargs not in (None, "1", "?"): @@ -271,9 +278,7 @@ def recurse(parser, prefix): this_optional_choices.append(str(choice)) if this_optional_choices: - this_choices_str = "' '".join(this_optional_choices) - choices.append( - f"{prefix}_{wordify(option_string)}_choices=('{this_choices_str}')") + choices.append(f"{prefix}_{wordify(option_string)}_choices={bash_listify(this_optional_choices)}") # Check for nargs. if optional.nargs is not None and optional.nargs != 1: From f8fbb0c1657ee7f969e7da16bfc79a80eeb7ca77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=A3o=20Afonso?= Date: Mon, 5 May 2025 17:15:59 +0100 Subject: [PATCH 2/3] Implement file globbing support This is tested locally in tcsh and bash, zsh is untested. There should not be any regressions, the test suite passes. --- shtab/__init__.py | 95 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 73 insertions(+), 22 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 58561c3..160e657 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -18,9 +18,9 @@ from functools import total_ordering from itertools import starmap from string import Template -from typing import Any, Dict, List, Sequence +from typing import Any, Dict, List, Mapping from typing import Optional as Opt -from typing import Union +from typing import Sequence, Tuple, Union # version detector. Precedence: installed dist, git, 'UNKNOWN' try: @@ -32,7 +32,7 @@ __version__ = get_version(root="..", relative_to=__file__) except (ImportError, LookupError): __version__ = "UNKNOWN" -__all__ = ["complete", "add_argument_to", "SUPPORTED_SHELLS", "FILE", "DIRECTORY", "DIR"] +__all__ = ["complete", "add_argument_to", "SUPPORTED_SHELLS", "FILE", "DIRECTORY", "DIR", "fglob"] log = logging.getLogger(__name__) SUPPORTED_SHELLS: List[str] = [] @@ -51,6 +51,15 @@ ) +def fglob(fglob: str): + '''Glob files''' + return { + '__glob__': fglob, + 'bash': '_shtab_compgen_files', # Uses `__glob__` internally + 'zsh': f"_files -g '{fglob}'", + 'tcsh': f'f:{fglob}',} + + class _ShtabPrintCompletionAction(Action): pass @@ -124,8 +133,26 @@ class Required: def complete2pattern(opt_complete, shell: str, choice_type2fn) -> str: - return (opt_complete.get(shell, "") - if isinstance(opt_complete, dict) else choice_type2fn[opt_complete]) + if isinstance(opt_complete, dict): + return opt_complete.get(shell, "") + else: + return choice_type2fn[opt_complete] + + +def bash_complete2compgen( + opt_complete: Mapping[str, str], + shell: str, + choice_type2fn: Mapping[str, str], +) -> Tuple[str, Tuple[str]]: + # Same inputs as `complete2pattern` + options = [] + if isinstance(opt_complete, dict): + if '__glob__' in opt_complete: + option_glob = opt_complete['__glob__'] + options.extend(['-X', f'!{option_glob}']) + return opt_complete.get(shell), tuple(options) + else: + return choice_type2fn[opt_complete], tuple(options) def bash_listify(lst: Sequence[str]) -> str: @@ -194,8 +221,11 @@ def recurse(parser, prefix): if hasattr(positional, "complete"): # shtab `.complete = ...` functions - comp_pattern = complete2pattern(positional.complete, "bash", choice_type2fn) - compgens.append(f"{prefix}_pos_{i}_COMPGEN={comp_pattern}") + comp_gen, comp_genopts = bash_complete2compgen(positional.complete, "bash", + choice_type2fn) + compgens.extend([ + f"{prefix}_pos_{i}_COMPGEN={comp_gen}", + f"{prefix}_pos_{i}_COMPGEN_options={bash_listify(comp_genopts)}",]) if positional.choices: # choices (including subparsers & shtab `.complete` functions) @@ -207,7 +237,9 @@ def recurse(parser, prefix): # append special completion type to `compgens` # NOTE: overrides `.complete` attribute log.debug(f"Choice.{choice.type}:{prefix}:{positional.dest}") - compgens.append(f"{prefix}_pos_{i}_COMPGEN={choice_type2fn[choice.type]}") + compgens.extend([ + f"{prefix}_pos_{i}_COMPGEN={choice_type2fn[choice.type]}", + f"{prefix}_pos_{i}_COMPGEN_options=()",]) elif isinstance(positional.choices, dict): # subparser, so append to list of subparsers & recurse log.debug("subcommand:%s", choice) @@ -237,7 +269,8 @@ def recurse(parser, prefix): this_positional_choices.append(str(choice)) if this_positional_choices: - choices.append(f"{prefix}_pos_{i}_choices={bash_listify(this_positional_choices)}") + choices.append( + f"{prefix}_pos_{i}_choices={bash_listify(this_positional_choices)}") # skip default `nargs` values if positional.nargs not in (None, "1", "?"): @@ -258,9 +291,12 @@ def recurse(parser, prefix): for option_string in optional.option_strings: if hasattr(optional, "complete"): # shtab `.complete = ...` functions - comp_pattern_str = complete2pattern(optional.complete, "bash", choice_type2fn) - compgens.append( - f"{prefix}_{wordify(option_string)}_COMPGEN={comp_pattern_str}") + comp_gen, comp_genopts = bash_complete2compgen(optional.complete, "bash", + choice_type2fn) + compgens.extend([ + f"{prefix}_{wordify(option_string)}_COMPGEN={comp_gen}", + f"{prefix}_{wordify(option_string)}_COMPGEN_options={bash_listify(comp_genopts)}", + ]) if optional.choices: # choices (including shtab `.complete` functions) @@ -270,15 +306,17 @@ def recurse(parser, prefix): # NOTE: overrides `.complete` attribute if isinstance(choice, Choice): log.debug(f"Choice.{choice.type}:{prefix}:{optional.dest}") - func_str = choice_type2fn[choice.type] - compgens.append( - f"{prefix}_{wordify(option_string)}_COMPGEN={func_str}") + compgens.extend([ + f"{prefix}_{wordify(option_string)}_COMPGEN={choice_type2fn[choice.type]}", + f"{prefix}_{wordify(option_string)}_COMPGEN_options=()",]) else: # simple choice this_optional_choices.append(str(choice)) if this_optional_choices: - choices.append(f"{prefix}_{wordify(option_string)}_choices={bash_listify(this_optional_choices)}") + choices.append( + f"{prefix}_{wordify(option_string)}_choices={bash_listify(this_optional_choices)}" + ) # Check for nargs. if optional.nargs is not None and optional.nargs != 1: @@ -328,7 +366,9 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): ${preamble} # $1=COMP_WORDS[1] _shtab_compgen_files() { - compgen -f -- $1 # files + local cur="$1" + shift + compgen -f "$@" -- "$cur" # files } # $1=COMP_WORDS[1] @@ -363,6 +403,13 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): local current_action_compgen_var=${current_action}_COMPGEN current_action_compgen="${!current_action_compgen_var-}" + if [ -z "$current_action_compgen" ]; then + current_action_compgen_options=() + else + local current_action_compgen_options_var="${current_action}_COMPGEN_options[@]" + current_action_compgen_options=("${!current_action_compgen_options_var}") + fi + local current_action_choices_var="${current_action}_choices[@]" current_action_choices="${!current_action_choices_var-}" @@ -393,6 +440,7 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): local current_action_args_start_index local current_action_choices local current_action_compgen + local -a current_action_compgen_options local current_action_is_positional local current_action_nargs local current_option_strings @@ -450,11 +498,14 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): # handle redirection operators COMPREPLY=( $(compgen -f -- "${completing_word}") ) else - # use choices & compgen - local IFS=$'\\n' # items may contain spaces, so delimit using newline - COMPREPLY=( $([ -n "${current_action_compgen}" ] \\ - && "${current_action_compgen}" "${completing_word}") ) - unset IFS + COMPREPLY=() + # use compgen + if [ -n "${current_action_compgen}" ]; then + local IFS=$'\\n' # items may contain spaces, so delimit using newline + COMPREPLY+=( $("${current_action_compgen}" "${current_action_compgen_options[@]}" "${completing_word}") ) + unset IFS + fi + # use choices COMPREPLY+=( $(compgen -W "${current_action_choices[*]}" -- "${completing_word}") ) fi From 201dcc237384b2f5879807c6e62c1e1c141595b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=A3o=20Afonso?= Date: Mon, 5 May 2025 17:18:30 +0100 Subject: [PATCH 3/3] PathComplete tests: showcase new globbing functionality --- examples/pathcomplete.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/pathcomplete.py b/examples/pathcomplete.py index 5a18e3b..d5aed10 100755 --- a/examples/pathcomplete.py +++ b/examples/pathcomplete.py @@ -15,7 +15,8 @@ def get_main_parser(): shtab.add_argument_to(parser, ["-s", "--print-completion"]) # magic! # file & directory tab complete - parser.add_argument("file", nargs="?").complete = shtab.FILE + parser.add_argument("file_all", nargs="?").complete = shtab.FILE + parser.add_argument("file_md", nargs="?").complete = shtab.fglob('*.md') parser.add_argument("--dir", default=".").complete = shtab.DIRECTORY return parser