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