853 lines
31 KiB
Python
853 lines
31 KiB
Python
|
|
import logging
|
||
|
|
import re
|
||
|
|
from argparse import (
|
||
|
|
ONE_OR_MORE,
|
||
|
|
REMAINDER,
|
||
|
|
SUPPRESS,
|
||
|
|
ZERO_OR_MORE,
|
||
|
|
Action,
|
||
|
|
ArgumentParser,
|
||
|
|
_AppendAction,
|
||
|
|
_AppendConstAction,
|
||
|
|
_CountAction,
|
||
|
|
_HelpAction,
|
||
|
|
_StoreConstAction,
|
||
|
|
_VersionAction,
|
||
|
|
)
|
||
|
|
from collections import defaultdict
|
||
|
|
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
|
||
|
|
|
||
|
|
# version detector. Precedence: installed dist, git, 'UNKNOWN'
|
||
|
|
try:
|
||
|
|
from ._dist_ver import __version__
|
||
|
|
except ImportError:
|
||
|
|
try:
|
||
|
|
from setuptools_scm import get_version
|
||
|
|
|
||
|
|
__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 = {}
|
||
|
|
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"]
|
||
|
|
DIRECTORY = DIR = CHOICE_FUNCTIONS["directory"]
|
||
|
|
FLAG_OPTION = (
|
||
|
|
_StoreConstAction,
|
||
|
|
_HelpAction,
|
||
|
|
_VersionAction,
|
||
|
|
_AppendConstAction,
|
||
|
|
_CountAction,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class _ShtabPrintCompletionAction(Action):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
OPTION_END = _HelpAction, _VersionAction, _ShtabPrintCompletionAction
|
||
|
|
OPTION_MULTI = _AppendAction, _AppendConstAction, _CountAction
|
||
|
|
|
||
|
|
|
||
|
|
def mark_completer(shell):
|
||
|
|
def wrapper(func):
|
||
|
|
if shell not in SUPPORTED_SHELLS:
|
||
|
|
SUPPORTED_SHELLS.append(shell)
|
||
|
|
_SUPPORTED_COMPLETERS[shell] = func
|
||
|
|
return func
|
||
|
|
|
||
|
|
return wrapper
|
||
|
|
|
||
|
|
|
||
|
|
def get_completer(shell: str):
|
||
|
|
try:
|
||
|
|
return _SUPPORTED_COMPLETERS[shell]
|
||
|
|
except KeyError:
|
||
|
|
supported = ",".join(SUPPORTED_SHELLS)
|
||
|
|
raise NotImplementedError(f"shell ({shell}) must be in {supported}")
|
||
|
|
|
||
|
|
|
||
|
|
@total_ordering
|
||
|
|
class Choice:
|
||
|
|
"""
|
||
|
|
Placeholder to mark a special completion `<type>`.
|
||
|
|
|
||
|
|
>>> ArgumentParser.add_argument(..., choices=[Choice("<type>")])
|
||
|
|
"""
|
||
|
|
def __init__(self, choice_type: str, required: bool = False) -> None:
|
||
|
|
"""
|
||
|
|
See below for parameters.
|
||
|
|
|
||
|
|
choice_type : internal `type` name
|
||
|
|
required : controls result of comparison to empty strings
|
||
|
|
"""
|
||
|
|
self.required = required
|
||
|
|
self.type = choice_type
|
||
|
|
|
||
|
|
def __repr__(self) -> str:
|
||
|
|
return self.type + ("" if self.required else "?")
|
||
|
|
|
||
|
|
def __cmp__(self, other: object) -> int:
|
||
|
|
if self.required:
|
||
|
|
return 0 if other else -1
|
||
|
|
return 0
|
||
|
|
|
||
|
|
def __eq__(self, other: object) -> bool:
|
||
|
|
return self.__cmp__(other) == 0
|
||
|
|
|
||
|
|
def __lt__(self, other: object) -> bool:
|
||
|
|
return self.__cmp__(other) < 0
|
||
|
|
|
||
|
|
|
||
|
|
class Optional:
|
||
|
|
"""Example: `ArgumentParser.add_argument(..., choices=Optional.FILE)`."""
|
||
|
|
|
||
|
|
FILE = [Choice("file")]
|
||
|
|
DIR = DIRECTORY = [Choice("directory")]
|
||
|
|
|
||
|
|
|
||
|
|
class Required:
|
||
|
|
"""Example: `ArgumentParser.add_argument(..., choices=Required.FILE)`."""
|
||
|
|
|
||
|
|
FILE = [Choice("file", True)]
|
||
|
|
DIR = DIRECTORY = [Choice("directory", True)]
|
||
|
|
|
||
|
|
|
||
|
|
def complete2pattern(opt_complete, shell: str, choice_type2fn) -> str:
|
||
|
|
return (opt_complete.get(shell, "")
|
||
|
|
if isinstance(opt_complete, dict) else choice_type2fn[opt_complete])
|
||
|
|
|
||
|
|
|
||
|
|
def wordify(string: str) -> str:
|
||
|
|
"""Replace non-word chars [\\W] with underscores [_]"""
|
||
|
|
return re.sub("\\W", "_", string)
|
||
|
|
|
||
|
|
|
||
|
|
def get_public_subcommands(sub):
|
||
|
|
"""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):
|
||
|
|
"""
|
||
|
|
Recursive subcommand parser traversal, returning lists of information on
|
||
|
|
commands (formatted for output to the completions script).
|
||
|
|
printing bash helper syntax.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
subparsers : list of subparsers for each parser
|
||
|
|
option_strings : list of options strings for each parser
|
||
|
|
compgens : list of shtab `.complete` functions corresponding to actions
|
||
|
|
choices : list of choices corresponding to actions
|
||
|
|
nargs : list of number of args allowed for each action (if not 0 or 1)
|
||
|
|
"""
|
||
|
|
choice_type2fn = {k: v["bash"] for k, v in CHOICE_FUNCTIONS.items()}
|
||
|
|
if choice_functions:
|
||
|
|
choice_type2fn.update(choice_functions)
|
||
|
|
|
||
|
|
def get_option_strings(parser):
|
||
|
|
"""Flattened list of all `parser`'s option strings."""
|
||
|
|
return sum(
|
||
|
|
(opt.option_strings for opt in parser._get_optional_actions() if opt.help != SUPPRESS),
|
||
|
|
[],
|
||
|
|
)
|
||
|
|
|
||
|
|
def recurse(parser, prefix):
|
||
|
|
"""recurse through subparsers, appending to the return lists"""
|
||
|
|
subparsers = []
|
||
|
|
option_strings = []
|
||
|
|
compgens = []
|
||
|
|
choices = []
|
||
|
|
nargs = []
|
||
|
|
|
||
|
|
# temp lists for recursion results
|
||
|
|
sub_subparsers = []
|
||
|
|
sub_option_strings = []
|
||
|
|
sub_compgens = []
|
||
|
|
sub_choices = []
|
||
|
|
sub_nargs = []
|
||
|
|
|
||
|
|
# positional arguments
|
||
|
|
discovered_subparsers = []
|
||
|
|
for i, positional in enumerate(parser._get_positional_actions()):
|
||
|
|
if positional.help == SUPPRESS:
|
||
|
|
continue
|
||
|
|
|
||
|
|
if hasattr(positional, "complete"):
|
||
|
|
# shtab `.complete = ...` functions
|
||
|
|
comp_pattern = complete2pattern(positional.complete, "bash", choice_type2fn)
|
||
|
|
compgens.append(f"{prefix}_pos_{i}_COMPGEN={comp_pattern}")
|
||
|
|
|
||
|
|
if positional.choices:
|
||
|
|
# choices (including subparsers & shtab `.complete` functions)
|
||
|
|
log.debug(f"choices:{prefix}:{sorted(positional.choices)}")
|
||
|
|
|
||
|
|
this_positional_choices = []
|
||
|
|
for choice in positional.choices:
|
||
|
|
if isinstance(choice, Choice):
|
||
|
|
# 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]}")
|
||
|
|
elif isinstance(positional.choices, dict):
|
||
|
|
# subparser, so append to list of subparsers & recurse
|
||
|
|
log.debug("subcommand:%s", choice)
|
||
|
|
public_cmds = get_public_subcommands(positional)
|
||
|
|
if choice in public_cmds:
|
||
|
|
discovered_subparsers.append(str(choice))
|
||
|
|
this_positional_choices.append(str(choice))
|
||
|
|
(
|
||
|
|
new_subparsers,
|
||
|
|
new_option_strings,
|
||
|
|
new_compgens,
|
||
|
|
new_choices,
|
||
|
|
new_nargs,
|
||
|
|
) = recurse(
|
||
|
|
positional.choices[choice],
|
||
|
|
f"{prefix}_{wordify(choice)}",
|
||
|
|
)
|
||
|
|
sub_subparsers.extend(new_subparsers)
|
||
|
|
sub_option_strings.extend(new_option_strings)
|
||
|
|
sub_compgens.extend(new_compgens)
|
||
|
|
sub_choices.extend(new_choices)
|
||
|
|
sub_nargs.extend(new_nargs)
|
||
|
|
else:
|
||
|
|
log.debug("skip:subcommand:%s", choice)
|
||
|
|
else:
|
||
|
|
# simple choice
|
||
|
|
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}')")
|
||
|
|
|
||
|
|
# skip default `nargs` values
|
||
|
|
if positional.nargs not in (None, "1", "?"):
|
||
|
|
nargs.append(f"{prefix}_pos_{i}_nargs={positional.nargs}")
|
||
|
|
|
||
|
|
if discovered_subparsers:
|
||
|
|
subparsers_str = "' '".join(discovered_subparsers)
|
||
|
|
subparsers.append(f"{prefix}_subparsers=('{subparsers_str}')")
|
||
|
|
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}')")
|
||
|
|
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
|
||
|
|
comp_pattern_str = complete2pattern(optional.complete, "bash", choice_type2fn)
|
||
|
|
compgens.append(
|
||
|
|
f"{prefix}_{wordify(option_string)}_COMPGEN={comp_pattern_str}")
|
||
|
|
|
||
|
|
if optional.choices:
|
||
|
|
# choices (including shtab `.complete` functions)
|
||
|
|
this_optional_choices = []
|
||
|
|
for choice in optional.choices:
|
||
|
|
# append special completion type to `compgens`
|
||
|
|
# NOTE: overrides `.complete` attribute
|
||
|
|
if isinstance(choice, Choice):
|
||
|
|
log.debug(f"Choice.{choice.type}:{prefix}:{optional.dest}")
|
||
|
|
func_str = choice_type2fn[choice.type]
|
||
|
|
compgens.append(
|
||
|
|
f"{prefix}_{wordify(option_string)}_COMPGEN={func_str}")
|
||
|
|
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}')")
|
||
|
|
|
||
|
|
# Check for nargs.
|
||
|
|
if optional.nargs is not None and optional.nargs != 1:
|
||
|
|
nargs.append(f"{prefix}_{wordify(option_string)}_nargs={optional.nargs}")
|
||
|
|
|
||
|
|
# append recursion results
|
||
|
|
subparsers.extend(sub_subparsers)
|
||
|
|
option_strings.extend(sub_option_strings)
|
||
|
|
compgens.extend(sub_compgens)
|
||
|
|
choices.extend(sub_choices)
|
||
|
|
nargs.extend(sub_nargs)
|
||
|
|
|
||
|
|
return subparsers, option_strings, compgens, choices, nargs
|
||
|
|
|
||
|
|
return recurse(root_parser, root_prefix)
|
||
|
|
|
||
|
|
|
||
|
|
@mark_completer("bash")
|
||
|
|
def complete_bash(parser, root_prefix=None, preamble="", choice_functions=None):
|
||
|
|
"""
|
||
|
|
Returns bash syntax autocompletion script.
|
||
|
|
|
||
|
|
See `complete` for arguments.
|
||
|
|
"""
|
||
|
|
root_prefix = wordify(f"_shtab_{root_prefix or parser.prog}")
|
||
|
|
subparsers, option_strings, compgens, choices, nargs = get_bash_commands(
|
||
|
|
parser, root_prefix, choice_functions=choice_functions)
|
||
|
|
|
||
|
|
# References:
|
||
|
|
# - https://www.gnu.org/software/bash/manual/html_node/
|
||
|
|
# 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]}"
|
||
|
|
local 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}
|
||
|
|
local 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
|
||
|
|
local this_word="${COMP_WORDS[$word_index]}"
|
||
|
|
|
||
|
|
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)"
|
||
|
|
_set_parser_defaults
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [[ " ${current_option_strings[@]} " == *" ${this_word} "* ]]; then
|
||
|
|
# 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
|
||
|
|
|
||
|
|
if [[ "$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"
|
||
|
|
_set_new_action "pos_${completed_positional_actions}" true
|
||
|
|
fi
|
||
|
|
else
|
||
|
|
pos_only=1 # "--" delimeter encountered
|
||
|
|
fi
|
||
|
|
|
||
|
|
let "word_index+=1"
|
||
|
|
done
|
||
|
|
|
||
|
|
# Generate the completions
|
||
|
|
|
||
|
|
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
|
||
|
|
# handle redirection operators
|
||
|
|
COMPREPLY=( $(compgen -f -- "${completing_word}") )
|
||
|
|
else
|
||
|
|
# use choices & compgen
|
||
|
|
local IFS=$'\\n' # items may contain spaces, so delimit using newline
|
||
|
|
COMPREPLY=( $([ -n "${current_action_compgen}" ] \\
|
||
|
|
&& "${current_action_compgen}" "${completing_word}") )
|
||
|
|
unset IFS
|
||
|
|
COMPREPLY+=( $(compgen -W "${current_action_choices[*]}" -- "${completing_word}") )
|
||
|
|
fi
|
||
|
|
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
complete -o filenames -F ${root_prefix} ${prog}""").safe_substitute(
|
||
|
|
subparsers="\n".join(subparsers),
|
||
|
|
option_strings="\n".join(option_strings),
|
||
|
|
compgens="\n".join(compgens),
|
||
|
|
choices="\n".join(choices),
|
||
|
|
nargs="\n".join(nargs),
|
||
|
|
preamble=("\n# Custom Preamble\n" + preamble +
|
||
|
|
"\n# End Custom Preamble\n" if preamble else ""),
|
||
|
|
root_prefix=root_prefix,
|
||
|
|
prog=parser.prog,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def escape_zsh(string):
|
||
|
|
# 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):
|
||
|
|
"""
|
||
|
|
Returns zsh syntax autocompletion script.
|
||
|
|
|
||
|
|
See `complete` for arguments.
|
||
|
|
"""
|
||
|
|
prog = parser.prog
|
||
|
|
root_prefix = wordify(f"_shtab_{root_prefix or prog}")
|
||
|
|
|
||
|
|
choice_type2fn = {k: v["zsh"] for k, v in CHOICE_FUNCTIONS.items()}
|
||
|
|
if choice_functions:
|
||
|
|
choice_type2fn.update(choice_functions)
|
||
|
|
|
||
|
|
def is_opt_end(opt):
|
||
|
|
return isinstance(opt, OPTION_END) or opt.nargs == REMAINDER
|
||
|
|
|
||
|
|
def is_opt_multiline(opt):
|
||
|
|
return isinstance(opt, OPTION_MULTI)
|
||
|
|
|
||
|
|
def format_optional(opt, parser):
|
||
|
|
get_help = parser._get_formatter()._expand_help
|
||
|
|
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 ""),
|
||
|
|
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 ""),
|
||
|
|
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 "",
|
||
|
|
).replace('""', ""))
|
||
|
|
|
||
|
|
def format_positional(opt, parser):
|
||
|
|
get_help = parser._get_formatter()._expand_help
|
||
|
|
return '"{nargs}:{help}:{pattern}"'.format(
|
||
|
|
nargs={ONE_OR_MORE: "(*)", ZERO_OR_MORE: "(*):", REMAINDER: "(-)*"}.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 "",
|
||
|
|
)
|
||
|
|
|
||
|
|
# {cmd: {"help": help, "arguments": [arguments]}}
|
||
|
|
all_commands = {
|
||
|
|
root_prefix: {
|
||
|
|
"cmd": prog, "arguments": [
|
||
|
|
format_optional(opt, parser)
|
||
|
|
for opt in parser._get_optional_actions() if opt.help != SUPPRESS] + [
|
||
|
|
format_positional(opt, parser) for opt in parser._get_positional_actions()
|
||
|
|
if opt.help != SUPPRESS and opt.choices is None],
|
||
|
|
"help": (parser.description
|
||
|
|
or "").strip().split("\n")[0], "commands": [], "paths": []}}
|
||
|
|
|
||
|
|
def recurse(parser, prefix, paths=None):
|
||
|
|
paths = paths or []
|
||
|
|
subcmds = []
|
||
|
|
for sub in parser._get_positional_actions():
|
||
|
|
if sub.help == SUPPRESS or not sub.choices:
|
||
|
|
continue
|
||
|
|
if not sub.choices or not isinstance(sub.choices, dict):
|
||
|
|
# positional argument
|
||
|
|
all_commands[prefix]["arguments"].append(format_positional(sub, parser))
|
||
|
|
else: # subparser
|
||
|
|
log.debug(f"choices:{prefix}:{sorted(sub.choices)}")
|
||
|
|
public_cmds = get_public_subcommands(sub)
|
||
|
|
for cmd, subparser in sub.choices.items():
|
||
|
|
if cmd not in public_cmds:
|
||
|
|
log.debug("skip:subcommand:%s", cmd)
|
||
|
|
continue
|
||
|
|
log.debug("subcommand:%s", cmd)
|
||
|
|
|
||
|
|
# optionals
|
||
|
|
arguments = [
|
||
|
|
format_optional(opt, parser) for opt in subparser._get_optional_actions()
|
||
|
|
if opt.help != SUPPRESS]
|
||
|
|
|
||
|
|
# positionals
|
||
|
|
arguments.extend(
|
||
|
|
format_positional(opt, parser)
|
||
|
|
for opt in subparser._get_positional_actions()
|
||
|
|
if not isinstance(opt.choices, dict) if opt.help != SUPPRESS)
|
||
|
|
|
||
|
|
# help text
|
||
|
|
formatter = subparser._get_formatter()
|
||
|
|
backup_width = formatter._width
|
||
|
|
formatter._width = 1234567 # large number to effectively disable wrapping
|
||
|
|
desc = formatter._format_text(subparser.description or "").strip()
|
||
|
|
formatter._width = backup_width
|
||
|
|
|
||
|
|
new_pref = f"{prefix}_{wordify(cmd)}"
|
||
|
|
options = all_commands[new_pref] = {
|
||
|
|
"cmd": cmd, "help": desc.split("\n")[0], "arguments": arguments,
|
||
|
|
"paths": [*paths, cmd]}
|
||
|
|
new_subcmds = recurse(subparser, new_pref, [*paths, cmd])
|
||
|
|
options["commands"] = {
|
||
|
|
all_commands[pref]["cmd"]: all_commands[pref]
|
||
|
|
for pref in new_subcmds if pref in all_commands}
|
||
|
|
subcmds.extend([*new_subcmds, new_pref])
|
||
|
|
log.debug("subcommands:%s:%s", cmd, options)
|
||
|
|
return subcmds
|
||
|
|
|
||
|
|
recurse(parser, root_prefix)
|
||
|
|
all_commands[root_prefix]["commands"] = {
|
||
|
|
options["cmd"]: options
|
||
|
|
for prefix, options in sorted(all_commands.items())
|
||
|
|
if len(options.get("paths", [])) < 2 and prefix != root_prefix}
|
||
|
|
subcommands = {
|
||
|
|
prefix: options
|
||
|
|
for prefix, options in all_commands.items() if options.get("commands")}
|
||
|
|
subcommands.setdefault(root_prefix, all_commands[root_prefix])
|
||
|
|
log.debug("subcommands:%s:%s", root_prefix, sorted(all_commands))
|
||
|
|
|
||
|
|
def command_case(prefix, options):
|
||
|
|
name = options["cmd"]
|
||
|
|
commands = options["commands"]
|
||
|
|
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)
|
||
|
|
|
||
|
|
return f"""\
|
||
|
|
{prefix}() {{
|
||
|
|
local context state line curcontext="$curcontext" one_or_more='(-)*' remainder='(*)'
|
||
|
|
|
||
|
|
if ((${{{prefix}_options[(I)${{(q)one_or_more}}*]}} + ${{{prefix}_options[(I)${{(q)remainder}}*]}} == 0)); then # noqa: E501
|
||
|
|
{prefix}_options+=(': :{prefix}_commands' '*::: :->{name}')
|
||
|
|
fi
|
||
|
|
_arguments -C -s ${prefix}_options
|
||
|
|
|
||
|
|
case $state in
|
||
|
|
{name})
|
||
|
|
words=($line[1] "${{words[@]}}")
|
||
|
|
(( CURRENT += 1 ))
|
||
|
|
curcontext="${{curcontext%:*:*}}:{prefix}-$line[1]:"
|
||
|
|
case $line[1] in
|
||
|
|
{cases}
|
||
|
|
esac
|
||
|
|
esac
|
||
|
|
}}
|
||
|
|
"""
|
||
|
|
|
||
|
|
def command_option(prefix, options):
|
||
|
|
arguments = "\n ".join(options["arguments"])
|
||
|
|
return f"""\
|
||
|
|
{prefix}_options=(
|
||
|
|
{arguments}
|
||
|
|
)
|
||
|
|
"""
|
||
|
|
|
||
|
|
def command_list(prefix, options):
|
||
|
|
name = " ".join([prog, *options["paths"]])
|
||
|
|
commands = "\n ".join(f'"{escape_zsh(cmd)}:{escape_zsh(opt["help"])}"'
|
||
|
|
for cmd, opt in sorted(options["commands"].items()))
|
||
|
|
return f"""
|
||
|
|
{prefix}_commands() {{
|
||
|
|
local _commands=(
|
||
|
|
{commands}
|
||
|
|
)
|
||
|
|
_describe '{name} commands' _commands
|
||
|
|
}}"""
|
||
|
|
|
||
|
|
preamble = (f"""\
|
||
|
|
# Custom Preamble
|
||
|
|
{preamble.rstrip()}
|
||
|
|
|
||
|
|
# End Custom Preamble
|
||
|
|
""" if preamble else "")
|
||
|
|
# References:
|
||
|
|
# - https://github.com/zsh-users/zsh-completions
|
||
|
|
# - http://zsh.sourceforge.net/Doc/Release/Completion-System.html
|
||
|
|
# - 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(
|
||
|
|
prog=prog,
|
||
|
|
root_prefix=root_prefix,
|
||
|
|
command_cases="\n".join(starmap(command_case, sorted(subcommands.items()))),
|
||
|
|
command_commands="\n".join(starmap(command_list, sorted(subcommands.items()))),
|
||
|
|
command_options="\n".join(starmap(command_option, sorted(all_commands.items()))),
|
||
|
|
preamble=preamble,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@mark_completer("tcsh")
|
||
|
|
def complete_tcsh(parser, root_prefix=None, preamble="", choice_functions=None):
|
||
|
|
"""
|
||
|
|
Return tcsh syntax autocompletion script.
|
||
|
|
|
||
|
|
root_prefix:
|
||
|
|
ignored (tcsh has no support for functions)
|
||
|
|
|
||
|
|
See `complete` for other arguments.
|
||
|
|
"""
|
||
|
|
optionals_single = set()
|
||
|
|
optionals_double = set()
|
||
|
|
specials = []
|
||
|
|
index_choices = 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):
|
||
|
|
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)
|
||
|
|
if complete_fn:
|
||
|
|
yield f"'{arg_type}/{arg_sel}/{complete_fn}/'"
|
||
|
|
|
||
|
|
def recurse_parser(cparser, positional_idx, requirements=None):
|
||
|
|
log_prefix = "| " * positional_idx
|
||
|
|
log.debug("%sParser @ %d", log_prefix, positional_idx)
|
||
|
|
if requirements:
|
||
|
|
log.debug("%s- Requires: %s", log_prefix, " ".join(requirements))
|
||
|
|
else:
|
||
|
|
requirements = []
|
||
|
|
|
||
|
|
for optional in cparser._get_optional_actions():
|
||
|
|
log.debug("%s| Optional: %s", log_prefix, optional.dest)
|
||
|
|
if optional.help != SUPPRESS:
|
||
|
|
# Mingle all optional arguments for all subparsers
|
||
|
|
for optional_str in optional.option_strings:
|
||
|
|
log.debug("%s| | %s", log_prefix, optional_str)
|
||
|
|
if optional_str.startswith('--'):
|
||
|
|
optionals_double.add(optional_str[2:])
|
||
|
|
elif optional_str.startswith('-'):
|
||
|
|
optionals_single.add(optional_str[1:])
|
||
|
|
specials.extend(get_specials(optional, 'n', optional_str))
|
||
|
|
|
||
|
|
for positional in cparser._get_positional_actions():
|
||
|
|
if positional.help != SUPPRESS:
|
||
|
|
positional_idx += 1
|
||
|
|
log.debug("%s| Positional #%d: %s", log_prefix, positional_idx, positional.dest)
|
||
|
|
index_choices[positional_idx][tuple(requirements)] = positional
|
||
|
|
if not requirements and isinstance(positional.choices, dict):
|
||
|
|
for subcmd, subparser in positional.choices.items():
|
||
|
|
log.debug("%s| | SubParser: %s", log_prefix, subcmd)
|
||
|
|
recurse_parser(subparser, positional_idx, requirements + [subcmd])
|
||
|
|
|
||
|
|
recurse_parser(parser, 0)
|
||
|
|
|
||
|
|
for idx, ndict in index_choices.items():
|
||
|
|
if len(ndict) == 1:
|
||
|
|
# Single choice, no requirements
|
||
|
|
arg = list(ndict.values())[0]
|
||
|
|
specials.extend(get_specials(arg, 'p', str(idx)))
|
||
|
|
else:
|
||
|
|
# Multiple requirements
|
||
|
|
nlist = []
|
||
|
|
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}'"])
|
||
|
|
nlist.append(f"( {checks_str} || false )")
|
||
|
|
# Ugly hack
|
||
|
|
nlist_str = ' || '.join(nlist)
|
||
|
|
specials.append(f"'p@{str(idx)}@`set cmd=($COMMAND_LINE); {nlist_str}`@'")
|
||
|
|
|
||
|
|
if optionals_double:
|
||
|
|
if optionals_single:
|
||
|
|
optionals_single.add('-')
|
||
|
|
else:
|
||
|
|
# Don't add a space after completing "--" from "-"
|
||
|
|
optionals_single = ('-', '-')
|
||
|
|
|
||
|
|
return Template("""\
|
||
|
|
# AUTOMATICALLY GENERATED by `shtab`
|
||
|
|
|
||
|
|
${preamble}
|
||
|
|
|
||
|
|
complete ${prog} \\
|
||
|
|
'c/--/(${optionals_double_str})/' \\
|
||
|
|
'c/-/(${optionals_single_str})/' \\
|
||
|
|
${optionals_special_str} \\
|
||
|
|
'p/*/()/'""").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)),
|
||
|
|
optionals_single_str=' '.join(sorted(optionals_single)),
|
||
|
|
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:
|
||
|
|
"""
|
||
|
|
shell:
|
||
|
|
bash/zsh/tcsh
|
||
|
|
root_prefix:
|
||
|
|
prefix for shell functions to avoid clashes (default: "_{parser.prog}")
|
||
|
|
preamble:
|
||
|
|
mapping shell to text to prepend to generated script
|
||
|
|
(e.g. `{"bash": "_myprog_custom_function(){ echo hello }"}`)
|
||
|
|
choice_functions:
|
||
|
|
*deprecated*
|
||
|
|
|
||
|
|
N.B. `parser.add_argument().complete = ...` can be used to define custom
|
||
|
|
completions (e.g. filenames). See <../examples/pathcomplete.py>.
|
||
|
|
"""
|
||
|
|
if isinstance(preamble, dict):
|
||
|
|
preamble = preamble.get(shell, "")
|
||
|
|
completer = get_completer(shell)
|
||
|
|
return completer(
|
||
|
|
parser,
|
||
|
|
root_prefix=root_prefix,
|
||
|
|
preamble=preamble,
|
||
|
|
choice_functions=choice_functions,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def completion_action(parent: Opt[ArgumentParser] = None, preamble: Union[str, Dict[str,
|
||
|
|
str]] = ""):
|
||
|
|
class PrintCompletionAction(_ShtabPrintCompletionAction):
|
||
|
|
def __call__(self, parser, namespace, values, option_string=None):
|
||
|
|
print(complete(parent or parser, values, preamble=preamble))
|
||
|
|
parser.exit(0)
|
||
|
|
|
||
|
|
return PrintCompletionAction
|
||
|
|
|
||
|
|
|
||
|
|
def add_argument_to(
|
||
|
|
parser: ArgumentParser,
|
||
|
|
option_string: Union[str, List[str]] = "--print-completion",
|
||
|
|
help: str = "print shell completion script",
|
||
|
|
parent: Opt[ArgumentParser] = None,
|
||
|
|
preamble: Union[str, Dict[str, str]] = "",
|
||
|
|
):
|
||
|
|
"""
|
||
|
|
option_string:
|
||
|
|
iff positional (no `-` prefix) then `parser` is assumed to actually be
|
||
|
|
a subparser (subcommand mode)
|
||
|
|
parent:
|
||
|
|
required in subcommand mode
|
||
|
|
"""
|
||
|
|
if isinstance(option_string, str):
|
||
|
|
option_string = [option_string]
|
||
|
|
kwargs = {
|
||
|
|
"choices": SUPPORTED_SHELLS, "default": None, "help": help,
|
||
|
|
"action": completion_action(parent, preamble)}
|
||
|
|
if option_string[0][0] != "-": # subparser mode
|
||
|
|
kwargs.update(default=SUPPORTED_SHELLS[0], nargs="?")
|
||
|
|
assert parent is not None, "subcommand mode: parent required"
|
||
|
|
parser.add_argument(*option_string, **kwargs)
|
||
|
|
return parser
|