From b28260ab97d5356b6f14721236859246ea8061c2 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Fri, 10 Oct 2025 17:58:10 +0200 Subject: [PATCH 01/20] test: Compare exc.type with `is` E721 Use `is` and `is not` for type comparisons, or `isinstance()` for isinstance checks Signed-off-by: Philipp Hahn --- tests/test_shtab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_shtab.py b/tests/test_shtab.py index f9d5df0..fdc1451 100644 --- a/tests/test_shtab.py +++ b/tests/test_shtab.py @@ -313,7 +313,7 @@ def test_add_argument_to_positional(shell, caplog, capsys): completion_manual = shtab.complete(parser, shell=shell) with pytest.raises(SystemExit) as exc: sub._actions[-1](sub, Namespace(), shell) - assert exc.type == SystemExit + assert exc.type is SystemExit assert exc.value.code == 0 completion, err = capsys.readouterr() print(completion) From 7502cd2efe33ff11eea65a4bf13d1e3bc3fea1d5 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Fri, 10 Oct 2025 13:53:26 +0200 Subject: [PATCH 02/20] main() does not return a value > shtab/__main__.py:8: error: "main" does not return a value (it only ever returns None) Signed-off-by: Philipp Hahn --- shtab/__main__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/shtab/__main__.py b/shtab/__main__.py index 22c6f14..1018735 100644 --- a/shtab/__main__.py +++ b/shtab/__main__.py @@ -1,8 +1,7 @@ import logging -import sys from .main import main if __name__ == "__main__": logging.basicConfig(level=logging.INFO) - sys.exit(main(sys.argv[1:]) or 0) + main() From cacb7724e53dbc127d80184ade4de494a288390c Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Fri, 10 Oct 2025 13:33:47 +0200 Subject: [PATCH 03/20] Fix Action == SUPPRESS ArgumentParser._get_optional_actions() will always return instances of `Action` and never `SUPPRESS`, which is a `str`. Signed-off-by: Philipp Hahn --- shtab/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 8660d30..4e6698c 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -245,9 +245,6 @@ def recurse(parser, prefix): options_strings_str = "' '".join(get_option_strings(parser)) option_strings.append(f"{prefix}_option_strings=('{options_strings_str}')") for optional in parser._get_optional_actions(): - if optional == SUPPRESS: - continue - for option_string in optional.option_strings: if hasattr(optional, "complete"): # shtab `.complete = ...` functions From 5594fde069c5869679a6cb97499d7829490fb6f6 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Fri, 10 Oct 2025 15:22:39 +0200 Subject: [PATCH 04/20] bash: Fix detection of redirection Also handle input redirection. Also handle other file descriptors than STDOUT and STDERR. --- shtab/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 4e6698c..bfce92d 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -437,8 +437,7 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): if [[ $pos_only = 0 && "${completing_word}" == -* ]]; then # optional argument started: use option strings COMPREPLY=( $(compgen -W "${current_option_strings[*]}" -- "${completing_word}") ) - elif [[ "${previous_word}" == ">" || "${previous_word}" == ">>" || - "${previous_word}" =~ ^[12]">" || "${previous_word}" =~ ^[12]">>" ]]; then + elif [[ "${previous_word}" =~ ^[0-9\\&]*[\\<\\>]\\>?$ ]]; then # handle redirection operators COMPREPLY=( $(compgen -f -- "${completing_word}") ) else From d13f0ec5130f1089891ce5cfe8dfab18093d1bd5 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Thu, 9 Oct 2025 17:36:37 +0200 Subject: [PATCH 05/20] shellcheck: Remove backslash at end of line No backslash at the end of the line is required after && and ||. Signed-off-by: Philipp Hahn --- shtab/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index bfce92d..b551f26 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -416,10 +416,10 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): _set_new_action $this_word false fi - if [[ "$current_action_nargs" != "*" ]] && \\ - [[ "$current_action_nargs" != "+" ]] && \\ - [[ "$current_action_nargs" != "?" ]] && \\ - [[ "$current_action_nargs" != *"..." ]] && \\ + if [[ "$current_action_nargs" != "*" ]] && + [[ "$current_action_nargs" != "+" ]] && + [[ "$current_action_nargs" != "?" ]] && + [[ "$current_action_nargs" != *"..." ]] && (( $word_index + 1 - $current_action_args_start_index - $pos_only >= \\ $current_action_nargs )); then $current_action_is_positional && let "completed_positional_actions += 1" @@ -443,8 +443,8 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): 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}") ) + COMPREPLY=( $([ -n "${current_action_compgen}" ] && + "${current_action_compgen}" "${completing_word}") ) unset IFS COMPREPLY+=( $(compgen -W "${current_action_choices[*]}" -- "${completing_word}") ) fi From 9c05d1dd3ead7280dc7a1ba249564fa5700d22ad Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Thu, 9 Oct 2025 17:38:00 +0200 Subject: [PATCH 06/20] shellcheck: No $ needed in arithmetic context https://www.shellcheck.net/wiki/SC2004 Signed-off-by: Philipp Hahn --- shtab/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index b551f26..fa02c06 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -365,7 +365,7 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): current_action_nargs=1 fi - current_action_args_start_index=$(( $word_index + 1 - $pos_only )) + current_action_args_start_index=$(( word_index + 1 - pos_only )) current_action_is_positional=$2 } @@ -400,7 +400,7 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): # determine what arguments are appropriate for the current state # of the arg parser while [ $word_index -ne $COMP_CWORD ]; do - local this_word="${COMP_WORDS[$word_index]}" + local this_word="${COMP_WORDS[word_index]}" if [[ $pos_only = 1 || " $this_word " != " -- " ]]; then if [[ -n $sub_parsers && " ${sub_parsers[@]} " == *" ${this_word} "* ]]; then @@ -420,8 +420,8 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): [[ "$current_action_nargs" != "+" ]] && [[ "$current_action_nargs" != "?" ]] && [[ "$current_action_nargs" != *"..." ]] && - (( $word_index + 1 - $current_action_args_start_index - $pos_only >= \\ - $current_action_nargs )); then + (( word_index + 1 - current_action_args_start_index - pos_only >= current_action_nargs )) + then $current_action_is_positional && let "completed_positional_actions += 1" _set_new_action "pos_${completed_positional_actions}" true fi From 36033a3ad60c04cf09510ff28f7d8fc40c2c2d14 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Thu, 9 Oct 2025 17:40:39 +0200 Subject: [PATCH 07/20] shellcheck: Declare variables as integers Declare some variables as integers, so `var+=1` can be used. Signed-off-by: Philipp Hahn --- shtab/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index fa02c06..03c9c54 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -380,7 +380,7 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): ${root_prefix}() { local completing_word="${COMP_WORDS[COMP_CWORD]}" local previous_word="${COMP_WORDS[COMP_CWORD-1]}" - local completed_positional_actions + declare -i completed_positional_actions local current_action local current_action_args_start_index local current_action_choices @@ -392,7 +392,7 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): COMPREPLY=() local prefix=${root_prefix} - local word_index=0 + declare -i word_index=0 local pos_only=0 # "--" delimeter not encountered yet _set_parser_defaults word_index=1 @@ -422,14 +422,14 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): [[ "$current_action_nargs" != *"..." ]] && (( word_index + 1 - current_action_args_start_index - pos_only >= current_action_nargs )) then - $current_action_is_positional && let "completed_positional_actions += 1" + $current_action_is_positional && completed_positional_actions+=1 _set_new_action "pos_${completed_positional_actions}" true fi else pos_only=1 # "--" delimeter encountered fi - let "word_index+=1" + word_index+=1 done # Generate the completions From 310d66480ecde5dea0fef8e218f2f41eb651d0a6 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Thu, 9 Oct 2025 17:43:55 +0200 Subject: [PATCH 08/20] shellcheck: Quote arguments https://www.shellcheck.net/wiki/SC2086 Signed-off-by: Philipp Hahn --- shtab/__init__.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 03c9c54..20f3918 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -320,12 +320,12 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): ${preamble} # $1=COMP_WORDS[1] _shtab_compgen_files() { - compgen -f -- $1 # files + compgen -f -- "$1" # files } # $1=COMP_WORDS[1] _shtab_compgen_dirs() { - compgen -d -- $1 # recurse into subdirs + compgen -d -- "$1" # recurse into subdirs } # $1=COMP_WORDS[1] @@ -350,7 +350,7 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): # $2=positional action (bool) # set all identifiers for an action's parameters _set_new_action() { - current_action="${prefix}_$(_shtab_replace_nonword $1)" + current_action="${prefix}_$(_shtab_replace_nonword "$1")" local current_action_compgen_var=${current_action}_COMPGEN current_action_compgen="${!current_action_compgen_var-}" @@ -391,7 +391,7 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): local sub_parsers COMPREPLY=() - local prefix=${root_prefix} + local prefix="${root_prefix}" declare -i word_index=0 local pos_only=0 # "--" delimeter not encountered yet _set_parser_defaults @@ -399,13 +399,13 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): # determine what arguments are appropriate for the current state # of the arg parser - while [ $word_index -ne $COMP_CWORD ]; do + while [ "$word_index" -ne "$COMP_CWORD" ]; do local this_word="${COMP_WORDS[word_index]}" - if [[ $pos_only = 1 || " $this_word " != " -- " ]]; then - if [[ -n $sub_parsers && " ${sub_parsers[@]} " == *" ${this_word} "* ]]; then + if [[ "$pos_only" = 1 || " $this_word " != " -- " ]]; then + if [[ -n "$sub_parsers" && " ${sub_parsers[@]} " == *" ${this_word} "* ]]; then # valid subcommand: add it to the prefix & reset the current action - prefix="${prefix}_$(_shtab_replace_nonword $this_word)" + prefix="${prefix}_$(_shtab_replace_nonword "$this_word")" _set_parser_defaults fi @@ -413,7 +413,7 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): # a new action should be acquired (due to recognised option string or # no more input expected from current action); # the next positional action can fill in here - _set_new_action $this_word false + _set_new_action "$this_word" false fi if [[ "$current_action_nargs" != "*" ]] && @@ -422,7 +422,7 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): [[ "$current_action_nargs" != *"..." ]] && (( word_index + 1 - current_action_args_start_index - pos_only >= current_action_nargs )) then - $current_action_is_positional && completed_positional_actions+=1 + "$current_action_is_positional" && completed_positional_actions+=1 _set_new_action "pos_${completed_positional_actions}" true fi else @@ -434,7 +434,7 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): # Generate the completions - if [[ $pos_only = 0 && "${completing_word}" == -* ]]; then + if [[ "$pos_only" = 0 && "${completing_word}" == -* ]]; then # optional argument started: use option strings COMPREPLY=( $(compgen -W "${current_option_strings[*]}" -- "${completing_word}") ) elif [[ "${previous_word}" =~ ^[0-9\\&]*[\\<\\>]\\>?$ ]]; then From d8590c06feb35107c4d4d72b804075c63185cf6f Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Fri, 10 Oct 2025 15:06:46 +0200 Subject: [PATCH 09/20] shellcheck: Use mapfile to preserve blanks https://www.shellcheck.net/wiki/SC2207 Signed-off-by: Philipp Hahn --- shtab/__init__.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 20f3918..009537d 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -436,17 +436,16 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): if [[ "$pos_only" = 0 && "${completing_word}" == -* ]]; then # optional argument started: use option strings - COMPREPLY=( $(compgen -W "${current_option_strings[*]}" -- "${completing_word}") ) + mapfile -t COMPREPLY < <(compgen -W "${current_option_strings[*]}" -- "${completing_word}") elif [[ "${previous_word}" =~ ^[0-9\\&]*[\\<\\>]\\>?$ ]]; then # handle redirection operators - COMPREPLY=( $(compgen -f -- "${completing_word}") ) + mapfile -t 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+=( $(compgen -W "${current_action_choices[*]}" -- "${completing_word}") ) + [ -n "${current_action_compgen}" ] && + mapfile -t COMPREPLY < <("${current_action_compgen}" "${completing_word}") + mapfile -t -O "${#COMPREPLY[@]}" COMPREPLY < <( + compgen -W "${current_action_choices[*]}" -- "${completing_word}") fi return 0 From 86f171a1e1891252b6a8e936d710aaa42ffe98c7 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Fri, 10 Oct 2025 17:00:42 +0200 Subject: [PATCH 10/20] shellcheck: Use for-loop to walk words Signed-off-by: Philipp Hahn --- shtab/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 009537d..0b9a452 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -395,11 +395,10 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): declare -i word_index=0 local pos_only=0 # "--" delimeter not encountered yet _set_parser_defaults - word_index=1 # determine what arguments are appropriate for the current state # of the arg parser - while [ "$word_index" -ne "$COMP_CWORD" ]; do + for ((word_index=1;word_index Date: Fri, 10 Oct 2025 17:02:43 +0200 Subject: [PATCH 11/20] shellcheck: Use ARRAY[*] instead of ARRAY[@] SC2199 (error): Arrays implicitly concatenate in [[ ]]. Use a loop (or explicit * instead of @). Signed-off-by: Philipp Hahn --- shtab/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 0b9a452..e81c874 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -402,18 +402,18 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): local this_word="${COMP_WORDS[word_index]}" if [[ "$pos_only" = 1 || " $this_word " != " -- " ]]; then - if [[ -n "$sub_parsers" && " ${sub_parsers[@]} " == *" ${this_word} "* ]]; then + [ -n "$sub_parsers" ] && case " ${sub_parsers[*]} " in *" ${this_word} "*) # valid subcommand: add it to the prefix & reset the current action prefix="${prefix}_$(_shtab_replace_nonword "$this_word")" _set_parser_defaults - fi + esac - if [[ " ${current_option_strings[@]} " == *" ${this_word} "* ]]; then + case " ${current_option_strings[*]} " in *" ${this_word} "*) # a new action should be acquired (due to recognised option string or # no more input expected from current action); # the next positional action can fill in here _set_new_action "$this_word" false - fi + esac if [[ "$current_action_nargs" != "*" ]] && [[ "$current_action_nargs" != "+" ]] && From cd4fe8a689e136c1a8daf7b49876a0a87b10683b Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Mon, 13 Oct 2025 07:36:53 +0200 Subject: [PATCH 12/20] Raise minimum supported Python version to 3.8 Required for: - `typing.Protocol` - walrus operator Using `collections.abc` instead of `typing` requires 3.9 for type-checking only! Signed-off-by: Philipp Hahn --- .github/workflows/test.yml | 4 ++-- pyproject.toml | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cc77c38..76f79c7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,10 +8,10 @@ jobs: test: if: github.event_name != 'pull_request' || !contains('OWNER,MEMBER,COLLABORATOR', github.event.pull_request.author_association) name: Test py${{ matrix.python }} - runs-on: ubuntu-${{ matrix.python == 3.7 && '22.04' || 'latest' }} + runs-on: ubuntu-${{ 'latest' }} strategy: matrix: - python: [3.7, 3.12] + python: [3.8, 3.12] steps: - uses: actions/checkout@v4 with: {fetch-depth: 0} diff --git a/pyproject.toml b/pyproject.toml index fe5f7ca..a6af465 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ authors = [{name = "Casper da Costa-Luis", email = "casper.dcl@physics.org"}] maintainers = [{name = "Iterative", email = "support@iterative.ai"}] description = "Automagic shell tab completion for Python CLI applications" readme = "README.rst" -requires-python = ">=3.7" +requires-python = ">=3.8" keywords = ["tab", "complete", "completion", "shell", "bash", "zsh", "argparse"] license = {text = "Apache-2.0"} classifiers = [ @@ -49,7 +49,6 @@ classifiers = [ "Programming Language :: Other Scripting Engines", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", From 4f04000ac0bc6af1cf4172e65d9aac2d3c9fd481 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Thu, 9 Oct 2025 17:17:40 +0200 Subject: [PATCH 13/20] style: Convert from old typing to native types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typing.List[…] -> list[…] typing.Dict[…] -> dict[…] typing.Union[…] -> … | … typing.Optional[…] -> … | None Signed-off-by: Philipp Hahn --- shtab/__init__.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index e81c874..c57269f 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -18,9 +18,7 @@ from functools import total_ordering from itertools import starmap from string import Template -from typing import Any, Dict, List -from typing import Optional as Opt -from typing import Union +from typing import Any # version detector. Precedence: installed dist, git, 'UNKNOWN' try: @@ -35,9 +33,9 @@ __all__ = ["complete", "add_argument_to", "SUPPORTED_SHELLS", "FILE", "DIRECTORY", "DIR"] log = logging.getLogger(__name__) -SUPPORTED_SHELLS: List[str] = [] +SUPPORTED_SHELLS: list[str] = [] _SUPPORTED_COMPLETERS = {} -CHOICE_FUNCTIONS: Dict[str, Dict[str, str]] = { +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 = CHOICE_FUNCTIONS["file"] @@ -782,8 +780,8 @@ def recurse_parser(cparser, positional_idx, requirements=None): optionals_special_str=' \\\n '.join(specials)) -def complete(parser: ArgumentParser, shell: str = "bash", root_prefix: Opt[str] = None, - preamble: Union[str, Dict[str, str]] = "", choice_functions: Opt[Any] = None) -> str: +def complete(parser: ArgumentParser, shell: str = "bash", root_prefix: str | None = None, + preamble: str | dict[str, str] = "", choice_functions: Any | None = None) -> str: """ shell: bash/zsh/tcsh @@ -809,8 +807,8 @@ def complete(parser: ArgumentParser, shell: str = "bash", root_prefix: Opt[str] ) -def completion_action(parent: Opt[ArgumentParser] = None, preamble: Union[str, Dict[str, - str]] = ""): +def completion_action(parent: ArgumentParser | None = None, preamble: str | dict[str, + str] = ""): class PrintCompletionAction(_ShtabPrintCompletionAction): def __call__(self, parser, namespace, values, option_string=None): print(complete(parent or parser, values, preamble=preamble)) @@ -821,10 +819,10 @@ def __call__(self, parser, namespace, values, option_string=None): def add_argument_to( parser: ArgumentParser, - option_string: Union[str, List[str]] = "--print-completion", + option_string: str | list[str] = "--print-completion", help: str = "print shell completion script", - parent: Opt[ArgumentParser] = None, - preamble: Union[str, Dict[str, str]] = "", + parent: ArgumentParser | None = None, + preamble: str | dict[str, str] = "", ): """ option_string: From 821f152ac0f3e41013849dfaba51b81096588237 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Thu, 9 Oct 2025 17:20:24 +0200 Subject: [PATCH 14/20] Add more type annotations Signed-off-by: Philipp Hahn --- shtab/__init__.py | 140 ++++++++++++++++++++++++++++++++++------------ shtab/main.py | 14 +++-- 2 files changed, 112 insertions(+), 42 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index c57269f..30456b1 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import re from argparse import ( @@ -7,18 +9,23 @@ ZERO_OR_MORE, Action, ArgumentParser, + Namespace, _AppendAction, _AppendConstAction, _CountAction, _HelpAction, _StoreConstAction, + _SubParsersAction, _VersionAction, ) from collections import defaultdict from functools import total_ordering from itertools import starmap from string import Template -from typing import Any +from typing import TYPE_CHECKING, Any, Callable, Iterator, Sequence, cast + +if TYPE_CHECKING: + from typing import Protocol # version detector. Precedence: installed dist, git, 'UNKNOWN' try: @@ -30,11 +37,13 @@ __version__ = get_version(root="..", relative_to=__file__) except (ImportError, LookupError): __version__ = "UNKNOWN" + __all__ = ["complete", "add_argument_to", "SUPPORTED_SHELLS", "FILE", "DIRECTORY", "DIR"] + log = logging.getLogger(__name__) SUPPORTED_SHELLS: list[str] = [] -_SUPPORTED_COMPLETERS = {} +_SUPPORTED_COMPLETERS: dict[str, _Complete] = {} 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"}} @@ -56,9 +65,21 @@ class _ShtabPrintCompletionAction(Action): OPTION_END = _HelpAction, _VersionAction, _ShtabPrintCompletionAction OPTION_MULTI = _AppendAction, _AppendConstAction, _CountAction +if TYPE_CHECKING: -def mark_completer(shell): - def wrapper(func): + class _Complete(Protocol): + def __call__( + self, + parser: ArgumentParser, + root_prefix: str | None = None, + preamble: str = "", + choice_functions: Any | None = None, + ) -> str: + ... + + +def mark_completer(shell: str) -> Callable[[_Complete], _Complete]: + def wrapper(func: _Complete) -> _Complete: if shell not in SUPPORTED_SHELLS: SUPPORTED_SHELLS.append(shell) _SUPPORTED_COMPLETERS[shell] = func @@ -67,7 +88,7 @@ def wrapper(func): return wrapper -def get_completer(shell: str): +def get_completer(shell: str) -> _Complete: try: return _SUPPORTED_COMPLETERS[shell] except KeyError: @@ -121,7 +142,11 @@ class Required: DIR = DIRECTORY = [Choice("directory", True)] -def complete2pattern(opt_complete, shell: str, choice_type2fn) -> str: +def complete2pattern( + opt_complete: dict[str, str] | str, + shell: str, + choice_type2fn: dict[str, str], +) -> str: return (opt_complete.get(shell, "") if isinstance(opt_complete, dict) else choice_type2fn[opt_complete]) @@ -131,13 +156,17 @@ def wordify(string: str) -> str: return re.sub("\\W", "_", string) -def get_public_subcommands(sub): +def get_public_subcommands(sub: _SubParsersAction[Any]) -> set[str]: """Get all the publicly-visible subcommands for a given subparser.""" public_parsers = {id(sub.choices[i.dest]) for i in sub._get_subactions()} return {k for k, v in sub.choices.items() if id(v) in public_parsers} -def get_bash_commands(root_parser, root_prefix, choice_functions=None): +def get_bash_commands( + root_parser: ArgumentParser, + root_prefix: str, + choice_functions: Any | None = None, +) -> tuple[list[str], list[str], list[str], list[str], list[str]]: """ Recursive subcommand parser traversal, returning lists of information on commands (formatted for output to the completions script). @@ -154,14 +183,17 @@ def get_bash_commands(root_parser, root_prefix, choice_functions=None): if choice_functions: choice_type2fn.update(choice_functions) - def get_option_strings(parser): + def get_option_strings(parser) -> list[str]: """Flattened list of all `parser`'s option strings.""" return sum( (opt.option_strings for opt in parser._get_optional_actions() if opt.help != SUPPRESS), [], ) - def recurse(parser, prefix): + def recurse( + parser: ArgumentParser, + prefix: str, + ) -> tuple[list[str], list[str], list[str], list[str], list[str]]: """recurse through subparsers, appending to the return lists""" subparsers = [] option_strings = [] @@ -201,6 +233,7 @@ def recurse(parser, prefix): elif isinstance(positional.choices, dict): # subparser, so append to list of subparsers & recurse log.debug("subcommand:%s", choice) + assert isinstance(positional, _SubParsersAction) public_cmds = get_public_subcommands(positional) if choice in public_cmds: discovered_subparsers.append(str(choice)) @@ -287,7 +320,12 @@ def recurse(parser, prefix): @mark_completer("bash") -def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): +def complete_bash( + parser: ArgumentParser, + root_prefix: str | None = None, + preamble: str = "", + choice_functions: Any | None = None, +) -> str: """ Returns bash syntax autocompletion script. @@ -459,13 +497,18 @@ def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None): ) -def escape_zsh(string): +def escape_zsh(string: str) -> str: # excessive but safe return re.sub(r"([^\w\s.,()-])", r"\\\1", str(string)) @mark_completer("zsh") -def complete_zsh(parser, root_prefix=None, preamble="", choice_functions=None): +def complete_zsh( + parser: ArgumentParser, + root_prefix: str | None = None, + preamble: str = "", + choice_functions: Any | None = None, +) -> str: """ Returns zsh syntax autocompletion script. @@ -478,13 +521,13 @@ def complete_zsh(parser, root_prefix=None, preamble="", choice_functions=None): if choice_functions: choice_type2fn.update(choice_functions) - def is_opt_end(opt): + def is_opt_end(opt: Action) -> bool: return isinstance(opt, OPTION_END) or opt.nargs == REMAINDER - def is_opt_multiline(opt): + def is_opt_multiline(opt: Action) -> bool: return isinstance(opt, OPTION_MULTI) - def format_optional(opt, parser): + def format_optional(opt: Action, parser: ArgumentParser) -> str: get_help = parser._get_formatter()._expand_help return (('{nargs}{options}"[{help}]"' if isinstance( opt, FLAG_OPTION) else '{nargs}{options}"[{help}]:{dest}:{pattern}"').format( @@ -499,7 +542,7 @@ def format_optional(opt, parser): "({})".format(" ".join(map(str, opt.choices)))) if opt.choices else "", ).replace('""', "")) - def format_positional(opt, parser): + def format_positional(opt, parser: ArgumentParser): get_help = parser._get_formatter()._expand_help return '"{nargs}:{help}:{pattern}"'.format( nargs={ONE_OR_MORE: "(*)", ZERO_OR_MORE: "(*):", REMAINDER: "(-)*"}.get(opt.nargs, ""), @@ -511,7 +554,7 @@ def format_positional(opt, parser): ) # {cmd: {"help": help, "arguments": [arguments]}} - all_commands = { + all_commands: dict[str, dict[str, Any]] = { root_prefix: { "cmd": prog, "arguments": [ format_optional(opt, parser) @@ -521,7 +564,7 @@ def format_positional(opt, parser): "help": (parser.description or "").strip().split("\n")[0], "commands": [], "paths": []}} - def recurse(parser, prefix, paths=None): + def recurse(parser: ArgumentParser, prefix: str, paths=None): paths = paths or [] subcmds = [] for sub in parser._get_positional_actions(): @@ -532,6 +575,7 @@ def recurse(parser, prefix, paths=None): all_commands[prefix]["arguments"].append(format_positional(sub, parser)) else: # subparser log.debug(f"choices:{prefix}:{sorted(sub.choices)}") + assert isinstance(sub, _SubParsersAction) public_cmds = get_public_subcommands(sub) for cmd, subparser in sub.choices.items(): if cmd not in public_cmds: @@ -580,7 +624,7 @@ def recurse(parser, prefix, paths=None): subcommands.setdefault(root_prefix, all_commands[root_prefix]) log.debug("subcommands:%s:%s", root_prefix, sorted(all_commands)) - def command_case(prefix, options): + def command_case(prefix: str, options: dict[str, Any]) -> str: name = options["cmd"] commands = options["commands"] case_fmt_on_no_sub = """{name}) _arguments -C -s ${prefix}_{name_wordify}_options ;;""" @@ -615,7 +659,7 @@ def command_case(prefix, options): }} """ - def command_option(prefix, options): + def command_option(prefix: str, options) -> str: arguments = "\n ".join(options["arguments"]) return f"""\ {prefix}_options=( @@ -623,7 +667,7 @@ def command_option(prefix, options): ) """ - def command_list(prefix, options): + def command_list(prefix: str, options) -> str: name = " ".join([prog, *options["paths"]]) commands = "\n ".join(f'"{escape_zsh(cmd)}:{escape_zsh(opt["help"])}"' for cmd, opt in sorted(options["commands"].items())) @@ -679,7 +723,12 @@ def command_list(prefix, options): @mark_completer("tcsh") -def complete_tcsh(parser, root_prefix=None, preamble="", choice_functions=None): +def complete_tcsh( + parser: ArgumentParser, + root_prefix: str | None = None, + preamble: str = "", + choice_functions: Any | None = None, +) -> str: """ Return tcsh syntax autocompletion script. @@ -688,16 +737,16 @@ def complete_tcsh(parser, root_prefix=None, preamble="", choice_functions=None): See `complete` for other arguments. """ - optionals_single = set() - optionals_double = set() - specials = [] - index_choices = defaultdict(dict) + optionals_single: set[str] = set() + optionals_double: set[str] = set() + specials: list[str] = [] + index_choices: dict[int, dict[tuple, Action]] = defaultdict(dict) choice_type2fn = {k: v["tcsh"] for k, v in CHOICE_FUNCTIONS.items()} if choice_functions: choice_type2fn.update(choice_functions) - def get_specials(arg, arg_type, arg_sel): + def get_specials(arg: Action, arg_type: str, arg_sel: str) -> Iterator[str]: if arg.choices: choice_strs = ' '.join(map(str, arg.choices)) yield f"'{arg_type}/{arg_sel}/({choice_strs})/'" @@ -706,7 +755,11 @@ def get_specials(arg, arg_type, arg_sel): if complete_fn: yield f"'{arg_type}/{arg_sel}/{complete_fn}/'" - def recurse_parser(cparser, positional_idx, requirements=None): + def recurse_parser( + cparser: ArgumentParser, + positional_idx: int, + requirements: list[str] | None = None, + ) -> None: log_prefix = "| " * positional_idx log.debug("%sParser @ %d", log_prefix, positional_idx) if requirements: @@ -780,8 +833,13 @@ def recurse_parser(cparser, positional_idx, requirements=None): optionals_special_str=' \\\n '.join(specials)) -def complete(parser: ArgumentParser, shell: str = "bash", root_prefix: str | None = None, - preamble: str | dict[str, str] = "", choice_functions: Any | None = None) -> str: +def complete( + parser: ArgumentParser, + shell: str = "bash", + root_prefix: str | None = None, + preamble: str | dict[str, str] = "", + choice_functions: Any | None = None, +) -> str: """ shell: bash/zsh/tcsh @@ -807,11 +865,19 @@ def complete(parser: ArgumentParser, shell: str = "bash", root_prefix: str | Non ) -def completion_action(parent: ArgumentParser | None = None, preamble: str | dict[str, - str] = ""): +def completion_action( + parent: ArgumentParser | None = None, + preamble: str | dict[str, str] = "", +) -> type[_ShtabPrintCompletionAction]: class PrintCompletionAction(_ShtabPrintCompletionAction): - def __call__(self, parser, namespace, values, option_string=None): - print(complete(parent or parser, values, preamble=preamble)) + def __call__( + self, + parser: ArgumentParser, + namespace: Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, + ) -> None: + print(complete(parent or parser, cast(str, values), preamble=preamble)) parser.exit(0) return PrintCompletionAction @@ -823,7 +889,7 @@ def add_argument_to( help: str = "print shell completion script", parent: ArgumentParser | None = None, preamble: str | dict[str, str] = "", -): +) -> ArgumentParser: """ option_string: iff positional (no `-` prefix) then `parser` is assumed to actually be @@ -833,7 +899,7 @@ def add_argument_to( """ if isinstance(option_string, str): option_string = [option_string] - kwargs = { + kwargs: dict[str, Any] = { "choices": SUPPORTED_SHELLS, "default": None, "help": help, "action": completion_action(parent, preamble)} if option_string[0][0] != "-": # subparser mode diff --git a/shtab/main.py b/shtab/main.py index efe506e..e83201b 100644 --- a/shtab/main.py +++ b/shtab/main.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import argparse import logging import os @@ -5,13 +7,14 @@ from contextlib import contextmanager from importlib import import_module from pathlib import Path +from typing import IO, Callable, Iterator from . import SUPPORTED_SHELLS, __version__, add_argument_to, complete log = logging.getLogger(__name__) -def get_main_parser(): +def get_main_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="shtab") parser.add_argument("parser", help="importable parser (or function returning parser)") parser.add_argument("--version", action="version", version="%(prog)s " + __version__) @@ -34,13 +37,13 @@ def get_main_parser(): return parser -def main(argv=None): +def main(argv: list[str] | None = None) -> None: parser = get_main_parser() args = parser.parse_args(argv) logging.basicConfig(level=args.loglevel) log.debug(args) - module, other_parser = args.parser.rsplit(".", 1) + module, _, other_parser_name = args.parser.rpartition(".") if sys.path and sys.path[0]: # not blank so not searching curdir sys.path.insert(1, os.curdir) @@ -51,14 +54,15 @@ def main(argv=None): raise log.debug(str(err)) return - other_parser = getattr(module, other_parser) + other_parser: argparse.ArgumentParser | Callable[[], argparse.ArgumentParser] = getattr( + module, other_parser_name) if callable(other_parser): other_parser = other_parser() if args.prog: other_parser.prog = args.prog @contextmanager - def _open(out_path): + def _open(out_path: Path) -> Iterator[IO[str]]: if str(out_path) in ("-", "stdout"): yield sys.stdout else: From 4e96f86ce7e8171c1d7ae9ab225dbbc21acc69bc Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Fri, 10 Oct 2025 13:20:07 +0200 Subject: [PATCH 15/20] More manual typing code changes Signed-off-by: Philipp Hahn --- shtab/__init__.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 30456b1..31d2c46 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -529,6 +529,8 @@ def is_opt_multiline(opt: Action) -> bool: def format_optional(opt: Action, parser: ArgumentParser) -> str: get_help = parser._get_formatter()._expand_help + complete = getattr(opt, "complete", None) + choices = list(opt.choices or []) return (('{nargs}{options}"[{help}]"' if isinstance( opt, FLAG_OPTION) else '{nargs}{options}"[{help}]:{dest}:{pattern}"').format( nargs=('"(- : *)"' if is_opt_end(opt) else '"*"' if is_opt_multiline(opt) else ""), @@ -536,21 +538,23 @@ def format_optional(opt: Action, parser: ArgumentParser) -> str: > 1 else '"{}"'.format("".join(opt.option_strings))), help=escape_zsh(get_help(opt) if opt.help else ""), dest=opt.dest, - pattern=complete2pattern(opt.complete, "zsh", choice_type2fn) if hasattr( - opt, "complete") else - (choice_type2fn[opt.choices[0].type] if isinstance(opt.choices[0], Choice) else - "({})".format(" ".join(map(str, opt.choices)))) if opt.choices else "", + pattern=complete2pattern(complete, "zsh", choice_type2fn) if complete else + (choice_type2fn[choices[0].type] if isinstance(choices[0], Choice) else + "({})".format(" ".join(map(str, choices)))) if choices else "", ).replace('""', "")) def format_positional(opt, parser: ArgumentParser): get_help = parser._get_formatter()._expand_help + complete = getattr(opt, "complete", None) + choices = list(opt.choices or []) + NARGS: dict[str | int | None, + str] = {ONE_OR_MORE: "(*)", ZERO_OR_MORE: "(*):", REMAINDER: "(-)*"} return '"{nargs}:{help}:{pattern}"'.format( - nargs={ONE_OR_MORE: "(*)", ZERO_OR_MORE: "(*):", REMAINDER: "(-)*"}.get(opt.nargs, ""), + nargs=NARGS.get(opt.nargs, ""), help=escape_zsh((get_help(opt) if opt.help else opt.dest).strip().split("\n")[0]), - pattern=complete2pattern(opt.complete, "zsh", choice_type2fn) if hasattr( - opt, "complete") else - (choice_type2fn[opt.choices[0].type] if isinstance(opt.choices[0], Choice) else - "({})".format(" ".join(map(str, opt.choices)))) if opt.choices else "", + pattern=complete2pattern(complete, "zsh", choice_type2fn) if complete else + (choice_type2fn[choices[0].type] if isinstance(choices[0], Choice) else "({})".format( + " ".join(map(str, choices)))) if choices else "", ) # {cmd: {"help": help, "arguments": [arguments]}} @@ -814,7 +818,7 @@ def recurse_parser( optionals_single.add('-') else: # Don't add a space after completing "--" from "-" - optionals_single = ('-', '-') + optionals_single = ('-', '-') # type:ignore[assignment] return Template("""\ # AUTOMATICALLY GENERATED by `shtab` From aa7e0f637a81913fa26221693c85e5bcc57f9001 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Fri, 10 Oct 2025 12:29:07 +0200 Subject: [PATCH 16/20] Convert to list-comprehension Also get rid of `cases` changing type from `list[str]` to `str`. Signed-off-by: Philipp Hahn --- shtab/__init__.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 31d2c46..668d705 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -634,13 +634,12 @@ def command_case(prefix: str, options: dict[str, Any]) -> str: case_fmt_on_no_sub = """{name}) _arguments -C -s ${prefix}_{name_wordify}_options ;;""" case_fmt_on_sub = """{name}) {prefix}_{name_wordify} ;;""" - cases = [] - for _, options in sorted(commands.items()): - fmt = case_fmt_on_sub if options.get("commands") else case_fmt_on_no_sub - cases.append( - fmt.format(name=options["cmd"], name_wordify=wordify(options["cmd"]), - prefix=prefix)) - cases = "\n\t".expandtabs(8).join(cases) + cases = "\n\t".expandtabs(8).join( + (case_fmt_on_sub if options.get("commands") else case_fmt_on_no_sub).format( + name=options["cmd"], + name_wordify=wordify(options["cmd"]), + prefix=prefix, + ) for _, options in sorted(commands.items())) return f"""\ {prefix}() {{ From 60bf1055669a98bb3a2dfd077b68be6fbd37471e Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Fri, 10 Oct 2025 13:09:07 +0200 Subject: [PATCH 17/20] Re-implement get_option_strings Use list-comprehension instead of sum(); it's easier to grasp and faster. Signed-off-by: Philipp Hahn --- shtab/__init__.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 668d705..cbe8def 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -183,12 +183,11 @@ def get_bash_commands( if choice_functions: choice_type2fn.update(choice_functions) - def get_option_strings(parser) -> list[str]: + def get_option_strings(parser: ArgumentParser) -> list[str]: """Flattened list of all `parser`'s option strings.""" - return sum( - (opt.option_strings for opt in parser._get_optional_actions() if opt.help != SUPPRESS), - [], - ) + return [ + o for opt in parser._get_optional_actions() if opt.help != SUPPRESS + for o in opt.option_strings] def recurse( parser: ArgumentParser, @@ -543,7 +542,7 @@ def format_optional(opt: Action, parser: ArgumentParser) -> str: "({})".format(" ".join(map(str, choices)))) if choices else "", ).replace('""', "")) - def format_positional(opt, parser: ArgumentParser): + def format_positional(opt: Action, parser: ArgumentParser) -> str: get_help = parser._get_formatter()._expand_help complete = getattr(opt, "complete", None) choices = list(opt.choices or []) @@ -568,7 +567,7 @@ def format_positional(opt, parser: ArgumentParser): "help": (parser.description or "").strip().split("\n")[0], "commands": [], "paths": []}} - def recurse(parser: ArgumentParser, prefix: str, paths=None): + def recurse(parser: ArgumentParser, prefix: str, paths: list[str] | None = None) -> list[str]: paths = paths or [] subcmds = [] for sub in parser._get_positional_actions(): @@ -662,7 +661,7 @@ def command_case(prefix: str, options: dict[str, Any]) -> str: }} """ - def command_option(prefix: str, options) -> str: + def command_option(prefix: str, options: dict[str, Any]) -> str: arguments = "\n ".join(options["arguments"]) return f"""\ {prefix}_options=( @@ -670,7 +669,7 @@ def command_option(prefix: str, options) -> str: ) """ - def command_list(prefix: str, options) -> str: + def command_list(prefix: str, options: dict[str, Any]) -> str: name = " ".join([prog, *options["paths"]]) commands = "\n ".join(f'"{escape_zsh(cmd)}:{escape_zsh(opt["help"])}"' for cmd, opt in sorted(options["commands"].items())) @@ -743,7 +742,7 @@ def complete_tcsh( optionals_single: set[str] = set() optionals_double: set[str] = set() specials: list[str] = [] - index_choices: dict[int, dict[tuple, Action]] = defaultdict(dict) + index_choices: dict[int, dict[tuple[str, ...], Action]] = defaultdict(dict) choice_type2fn = {k: v["tcsh"] for k, v in CHOICE_FUNCTIONS.items()} if choice_functions: From 1fb805284435a0a83cf70ba8104e7b9e75b113b7 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Fri, 10 Oct 2025 16:38:19 +0200 Subject: [PATCH 18/20] Use shlex.quot() to do escaping Signed-off-by: Philipp Hahn --- shtab/__init__.py | 47 ++++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index cbe8def..5be123f 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -21,6 +21,7 @@ from collections import defaultdict from functools import total_ordering from itertools import starmap +from shlex import join, quote from string import Template from typing import TYPE_CHECKING, Any, Callable, Iterator, Sequence, cast @@ -216,7 +217,7 @@ def recurse( if hasattr(positional, "complete"): # shtab `.complete = ...` functions comp_pattern = complete2pattern(positional.complete, "bash", choice_type2fn) - compgens.append(f"{prefix}_pos_{i}_COMPGEN={comp_pattern}") + compgens.append(f"{prefix}_pos_{i}_COMPGEN={quote(comp_pattern)}") if positional.choices: # choices (including subparsers & shtab `.complete` functions) @@ -228,7 +229,8 @@ def recurse( # 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.append(f"{prefix}_pos_{i}_COMPGEN=" + f"{quote(choice_type2fn[choice.type])}") elif isinstance(positional.choices, dict): # subparser, so append to list of subparsers & recurse log.debug("subcommand:%s", choice) @@ -259,28 +261,25 @@ def recurse( 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=({join(this_positional_choices)})") # skip default `nargs` values if positional.nargs not in (None, "1", "?"): - nargs.append(f"{prefix}_pos_{i}_nargs={positional.nargs}") + nargs.append(f"{prefix}_pos_{i}_nargs={quote(str(positional.nargs))}") if discovered_subparsers: - subparsers_str = "' '".join(discovered_subparsers) - subparsers.append(f"{prefix}_subparsers=('{subparsers_str}')") + subparsers.append(f"{prefix}_subparsers=({join(discovered_subparsers)})") log.debug(f"subcommands:{prefix}:{discovered_subparsers}") # optional arguments - options_strings_str = "' '".join(get_option_strings(parser)) - option_strings.append(f"{prefix}_option_strings=('{options_strings_str}')") + option_strings.append(f"{prefix}_option_strings=({join(get_option_strings(parser))})") for optional in parser._get_optional_actions(): 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}") + compgens.append(f"{prefix}_{wordify(option_string)}_COMPGEN=" + f"{join(comp_pattern_str)}") if optional.choices: # choices (including shtab `.complete` functions) @@ -291,20 +290,20 @@ def recurse( 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.append(f"{prefix}_{wordify(option_string)}_COMPGEN=" + f"{quote(func_str)}") else: # simple choice 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=" + f"({join(this_optional_choices)})") # Check for nargs. if optional.nargs is not None and optional.nargs != 1: - nargs.append(f"{prefix}_{wordify(option_string)}_nargs={optional.nargs}") + nargs.append(f"{prefix}_{wordify(option_string)}_nargs=" + f"{quote(str(optional.nargs))}") # append recursion results subparsers.extend(sub_subparsers) @@ -496,11 +495,6 @@ def complete_bash( ) -def escape_zsh(string: str) -> str: - # excessive but safe - return re.sub(r"([^\w\s.,()-])", r"\\\1", str(string)) - - @mark_completer("zsh") def complete_zsh( parser: ArgumentParser, @@ -535,7 +529,7 @@ def format_optional(opt: Action, parser: ArgumentParser) -> str: nargs=('"(- : *)"' if is_opt_end(opt) else '"*"' if is_opt_multiline(opt) else ""), options=("{{{}}}".format(",".join(opt.option_strings)) if len(opt.option_strings) > 1 else '"{}"'.format("".join(opt.option_strings))), - help=escape_zsh(get_help(opt) if opt.help else ""), + help=quote(get_help(opt) if opt.help else ""), dest=opt.dest, pattern=complete2pattern(complete, "zsh", choice_type2fn) if complete else (choice_type2fn[choices[0].type] if isinstance(choices[0], Choice) else @@ -550,7 +544,7 @@ def format_positional(opt: Action, parser: ArgumentParser) -> str: str] = {ONE_OR_MORE: "(*)", ZERO_OR_MORE: "(*):", REMAINDER: "(-)*"} return '"{nargs}:{help}:{pattern}"'.format( nargs=NARGS.get(opt.nargs, ""), - help=escape_zsh((get_help(opt) if opt.help else opt.dest).strip().split("\n")[0]), + help=quote((get_help(opt) if opt.help else opt.dest).strip().split("\n")[0]), pattern=complete2pattern(complete, "zsh", choice_type2fn) if complete else (choice_type2fn[choices[0].type] if isinstance(choices[0], Choice) else "({})".format( " ".join(map(str, choices)))) if choices else "", @@ -671,7 +665,7 @@ def command_option(prefix: str, options: dict[str, Any]) -> str: def command_list(prefix: str, options: dict[str, Any]) -> str: name = " ".join([prog, *options["paths"]]) - commands = "\n ".join(f'"{escape_zsh(cmd)}:{escape_zsh(opt["help"])}"' + commands = "\n ".join(f'{quote(cmd)}:{quote(opt["help"])}' for cmd, opt in sorted(options["commands"].items())) return f""" {prefix}_commands() {{ @@ -804,8 +798,7 @@ def recurse_parser( for nn, arg in ndict.items(): if arg.choices: checks = [f'[ "$cmd[{iidx}]" == "{n}" ]' for iidx, n in enumerate(nn, start=2)] - choices_str = "' '".join(arg.choices) - checks_str = ' && '.join(checks + [f"echo '{choices_str}'"]) + checks_str = ' && '.join(checks + [f"echo {join(arg.choices)}"]) nlist.append(f"( {checks_str} || false )") # Ugly hack nlist_str = ' || '.join(nlist) From a9f230eb55a545ed46a88d382a611357da113422 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Fri, 10 Oct 2025 17:59:47 +0200 Subject: [PATCH 19/20] Fix accessing attribute .complete Drop Python 3.7 support as the Walrus-operator only available since 3.8. Signed-off-by: Philipp Hahn --- shtab/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 5be123f..d7096b4 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -214,9 +214,9 @@ def recurse( if positional.help == SUPPRESS: continue - if hasattr(positional, "complete"): + if complete := getattr(positional, "complete", None): # shtab `.complete = ...` functions - comp_pattern = complete2pattern(positional.complete, "bash", choice_type2fn) + comp_pattern = complete2pattern(complete, "bash", choice_type2fn) compgens.append(f"{prefix}_pos_{i}_COMPGEN={quote(comp_pattern)}") if positional.choices: @@ -275,9 +275,9 @@ def recurse( option_strings.append(f"{prefix}_option_strings=({join(get_option_strings(parser))})") for optional in parser._get_optional_actions(): for option_string in optional.option_strings: - if hasattr(optional, "complete"): + if complete := getattr(optional, "complete", None): # shtab `.complete = ...` functions - comp_pattern_str = complete2pattern(optional.complete, "bash", choice_type2fn) + comp_pattern_str = complete2pattern(complete, "bash", choice_type2fn) compgens.append(f"{prefix}_{wordify(option_string)}_COMPGEN=" f"{join(comp_pattern_str)}") @@ -746,8 +746,8 @@ def get_specials(arg: Action, arg_type: str, arg_sel: str) -> Iterator[str]: if arg.choices: choice_strs = ' '.join(map(str, arg.choices)) yield f"'{arg_type}/{arg_sel}/({choice_strs})/'" - elif hasattr(arg, 'complete'): - complete_fn = complete2pattern(arg.complete, 'tcsh', choice_type2fn) + elif complete := getattr(arg, 'complete', None): + complete_fn = complete2pattern(complete, 'tcsh', choice_type2fn) if complete_fn: yield f"'{arg_type}/{arg_sel}/{complete_fn}/'" From 0abe1be633e2b37878e7e60f719e11ae0c7bc073 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Fri, 10 Oct 2025 17:26:48 +0200 Subject: [PATCH 20/20] Move shell fragments to separate files Signed-off-by: Philipp Hahn --- shtab/__init__.py | 190 ++++------------------------------------------ shtab/bash.sh | 145 +++++++++++++++++++++++++++++++++++ shtab/tcsh.sh | 10 +++ shtab/zsh.sh | 21 +++++ 4 files changed, 189 insertions(+), 177 deletions(-) create mode 100644 shtab/bash.sh create mode 100644 shtab/tcsh.sh create mode 100644 shtab/zsh.sh diff --git a/shtab/__init__.py b/shtab/__init__.py index d7096b4..2a7883b 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -21,6 +21,7 @@ from collections import defaultdict from functools import total_ordering from itertools import starmap +from pkgutil import get_data from shlex import join, quote from string import Template from typing import TYPE_CHECKING, Any, Callable, Iterator, Sequence, cast @@ -338,151 +339,10 @@ def complete_bash( # Programmable-Completion.html # - https://opensource.com/article/18/3/creating-bash-completion-script # - https://stackoverflow.com/questions/12933362 - return Template("""\ -# AUTOMATICALLY GENERATED by `shtab` - -${subparsers} - -${option_strings} - -${compgens} - -${choices} - -${nargs} - -${preamble} -# $1=COMP_WORDS[1] -_shtab_compgen_files() { - compgen -f -- "$1" # files -} - -# $1=COMP_WORDS[1] -_shtab_compgen_dirs() { - compgen -d -- "$1" # recurse into subdirs -} - -# $1=COMP_WORDS[1] -_shtab_replace_nonword() { - echo "${1//[^[:word:]]/_}" -} - -# set default values (called for the initial parser & any subparsers) -_set_parser_defaults() { - local subparsers_var="${prefix}_subparsers[@]" - sub_parsers=${!subparsers_var-} - - local current_option_strings_var="${prefix}_option_strings[@]" - current_option_strings=${!current_option_strings_var} - - completed_positional_actions=0 - - _set_new_action "pos_${completed_positional_actions}" true -} - -# $1=action identifier -# $2=positional action (bool) -# set all identifiers for an action's parameters -_set_new_action() { - current_action="${prefix}_$(_shtab_replace_nonword "$1")" - - local current_action_compgen_var=${current_action}_COMPGEN - current_action_compgen="${!current_action_compgen_var-}" - - local current_action_choices_var="${current_action}_choices[@]" - current_action_choices="${!current_action_choices_var-}" - - local current_action_nargs_var="${current_action}_nargs" - if [ -n "${!current_action_nargs_var-}" ]; then - current_action_nargs="${!current_action_nargs_var}" - else - current_action_nargs=1 - fi - - current_action_args_start_index=$(( word_index + 1 - pos_only )) - - current_action_is_positional=$2 -} - -# Notes: -# `COMPREPLY`: what will be rendered after completion is triggered -# `completing_word`: currently typed word to generate completions for -# `${!var}`: evaluates the content of `var` and expand its content as a variable -# hello="world" -# x="hello" -# ${!x} -> ${hello} -> "world" -${root_prefix}() { - local completing_word="${COMP_WORDS[COMP_CWORD]}" - local previous_word="${COMP_WORDS[COMP_CWORD-1]}" - declare -i completed_positional_actions - local current_action - local current_action_args_start_index - local current_action_choices - local current_action_compgen - local current_action_is_positional - local current_action_nargs - local current_option_strings - local sub_parsers - COMPREPLY=() - - local prefix="${root_prefix}" - declare -i word_index=0 - local pos_only=0 # "--" delimeter not encountered yet - _set_parser_defaults - - # determine what arguments are appropriate for the current state - # of the arg parser - for ((word_index=1;word_index= current_action_nargs )) - then - "$current_action_is_positional" && completed_positional_actions+=1 - _set_new_action "pos_${completed_positional_actions}" true - fi - else - pos_only=1 # "--" delimeter encountered - fi - done - - # Generate the completions - - if [[ "$pos_only" = 0 && "${completing_word}" == -* ]]; then - # optional argument started: use option strings - mapfile -t COMPREPLY < <(compgen -W "${current_option_strings[*]}" -- "${completing_word}") - elif [[ "${previous_word}" =~ ^[0-9\\&]*[\\<\\>]\\>?$ ]]; then - # handle redirection operators - mapfile -t COMPREPLY < <(compgen -f -- "${completing_word}") - else - # use choices & compgen - [ -n "${current_action_compgen}" ] && - mapfile -t COMPREPLY < <("${current_action_compgen}" "${completing_word}") - mapfile -t -O "${#COMPREPLY[@]}" COMPREPLY < <( - compgen -W "${current_action_choices[*]}" -- "${completing_word}") - fi - - return 0 -} - -complete -o filenames -F ${root_prefix} ${prog}""").safe_substitute( + data = get_data(__name__, "bash.sh") + assert data + tmpl = Template(data.decode()) + return tmpl.safe_substitute( subparsers="\n".join(subparsers), option_strings="\n".join(option_strings), compgens="\n".join(compgens), @@ -687,28 +547,10 @@ def command_list(prefix: str, options: dict[str, Any]) -> str: # - https://mads-hartmann.com/2017/08/06/ # writing-zsh-completion-scripts.html # - http://www.linux-mag.com/id/1106/ - return Template("""\ -#compdef ${prog} - -# AUTOMATICALLY GENERATED by `shtab` - -${command_commands} - -${command_options} - -${command_cases} -${preamble} - -typeset -A opt_args - -if [[ $zsh_eval_context[-1] == eval ]]; then - # eval/source/. command, register function for later - compdef ${root_prefix} -N ${prog} -else - # autoload from fpath, call function directly - ${root_prefix} "$@\" -fi -""").safe_substitute( + data = get_data(__name__, "zsh.sh") + assert data + tmpl = Template(data.decode()) + return tmpl.safe_substitute( prog=prog, root_prefix=root_prefix, command_cases="\n".join(starmap(command_case, sorted(subcommands.items()))), @@ -811,16 +653,10 @@ def recurse_parser( # Don't add a space after completing "--" from "-" optionals_single = ('-', '-') # type:ignore[assignment] - return Template("""\ -# AUTOMATICALLY GENERATED by `shtab` - -${preamble} - -complete ${prog} \\ - 'c/--/(${optionals_double_str})/' \\ - 'c/-/(${optionals_single_str})/' \\ - ${optionals_special_str} \\ - 'p/*/()/'""").safe_substitute( + data = get_data(__name__, "tcsh.sh") + assert data + tmpl = Template(data.decode()) + return tmpl.safe_substitute( preamble=("\n# Custom Preamble\n" + preamble + "\n# End Custom Preamble\n" if preamble else ""), root_prefix=root_prefix, prog=parser.prog, optionals_double_str=' '.join(sorted(optionals_double)), diff --git a/shtab/bash.sh b/shtab/bash.sh new file mode 100644 index 0000000..ac89f2d --- /dev/null +++ b/shtab/bash.sh @@ -0,0 +1,145 @@ +# shellcheck shell=bash +# AUTOMATICALLY GENERATED by `shtab` + +${subparsers} + +${option_strings} + +${compgens} + +${choices} + +${nargs} + +${preamble} +# $1=COMP_WORDS[1] +_shtab_compgen_files() { + compgen -f -- "$1" # files +} + +# $1=COMP_WORDS[1] +_shtab_compgen_dirs() { + compgen -d -- "$1" # recurse into subdirs +} + +# $1=COMP_WORDS[1] +_shtab_replace_nonword() { + echo "${1//[^[:word:]]/_}" +} + +# set default values (called for the initial parser & any subparsers) +_set_parser_defaults() { + local subparsers_var="${prefix}_subparsers[@]" + sub_parsers=${!subparsers_var-} + + local current_option_strings_var="${prefix}_option_strings[@]" + current_option_strings=${!current_option_strings_var} + + completed_positional_actions=0 + + _set_new_action "pos_${completed_positional_actions}" true +} + +# $1=action identifier +# $2=positional action (bool) +# set all identifiers for an action's parameters +_set_new_action() { + current_action="${prefix}_$(_shtab_replace_nonword "$1")" + + local current_action_compgen_var=${current_action}_COMPGEN + current_action_compgen="${!current_action_compgen_var-}" + + local current_action_choices_var="${current_action}_choices[@]" + current_action_choices="${!current_action_choices_var-}" + + local current_action_nargs_var="${current_action}_nargs" + if [ -n "${!current_action_nargs_var-}" ]; then + current_action_nargs="${!current_action_nargs_var}" + else + current_action_nargs=1 + fi + + current_action_args_start_index=$(( word_index + 1 - pos_only )) + + current_action_is_positional=$2 +} + +# Notes: +# `COMPREPLY`: what will be rendered after completion is triggered +# `completing_word`: currently typed word to generate completions for +# `${!var}`: evaluates the content of `var` and expand its content as a variable +# hello="world" +# x="hello" +# ${!x} -> ${hello} -> "world" +${root_prefix}() { + local completing_word="${COMP_WORDS[COMP_CWORD]}" + local previous_word="${COMP_WORDS[COMP_CWORD-1]}" + declare -i completed_positional_actions + local current_action + local current_action_args_start_index + local current_action_choices + local current_action_compgen + local current_action_is_positional + local current_action_nargs + local current_option_strings + local sub_parsers + COMPREPLY=() + + local prefix="${root_prefix}" + declare -i word_index=0 + local pos_only=0 # "--" delimeter not encountered yet + _set_parser_defaults + + # determine what arguments are appropriate for the current state + # of the arg parser + for ((word_index=1;word_index= current_action_nargs )) + then + "$current_action_is_positional" && completed_positional_actions+=1 + _set_new_action "pos_${completed_positional_actions}" true + fi + else + pos_only=1 # "--" delimeter encountered + fi + done + + # Generate the completions + + if [[ "$pos_only" = 0 && "${completing_word}" == -* ]]; then + # optional argument started: use option strings + mapfile -t COMPREPLY < <(compgen -W "${current_option_strings[*]}" -- "${completing_word}") + elif [[ "${previous_word}" =~ ^[0-9\&]*[\<\>]\>?$ ]]; then + # handle redirection operators + mapfile -t COMPREPLY < <(compgen -f -- "${completing_word}") + else + # use choices & compgen + [ -n "${current_action_compgen}" ] && + mapfile -t COMPREPLY < <("${current_action_compgen}" "${completing_word}") + mapfile -t -O "${#COMPREPLY[@]}" COMPREPLY < <( + compgen -W "${current_action_choices[*]}" -- "${completing_word}") + fi + + return 0 +} + +complete -o filenames -F ${root_prefix} ${prog} diff --git a/shtab/tcsh.sh b/shtab/tcsh.sh new file mode 100644 index 0000000..5974758 --- /dev/null +++ b/shtab/tcsh.sh @@ -0,0 +1,10 @@ +# shellcheck shell=tcsh +# AUTOMATICALLY GENERATED by `shtab` + +${preamble} + +complete ${prog} \ + 'c/--/(${optionals_double_str})/' \ + 'c/-/(${optionals_single_str})/' \ + ${optionals_special_str} \ + 'p/*/()/' diff --git a/shtab/zsh.sh b/shtab/zsh.sh new file mode 100644 index 0000000..c629cbc --- /dev/null +++ b/shtab/zsh.sh @@ -0,0 +1,21 @@ +# shellcheck shell=zsh +#compdef ${prog} + +# AUTOMATICALLY GENERATED by `shtab` + +${command_commands} + +${command_options} + +${command_cases} +${preamble} + +typeset -A opt_args + +if [[ $zsh_eval_context[-1] == eval ]]; then + # eval/source/. command, register function for later + compdef ${root_prefix} -N ${prog} +else + # autoload from fpath, call function directly + ${root_prefix} "$@" +fi