diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54b47b7..47df910 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,15 +20,15 @@ Most of the magic lives in [`shtab/__init__.py`](./shtab/__init__.py). - `complete_bash()` - `complete_zsh()` - ... - - `Optional()`, `Required()`, `Choice()` - helpers for advanced completion - (e.g. dirs, files, `*.txt`) + - `add_argument_to()` - convenience function for library integration + - `Optional()`, `Required()`, `Choice()` - legacy helpers for advanced completion (e.g. dirs, files, `*.txt`) - [`main.py`](./shtab/main.py) - `get_main_parser()` - returns `shtab`'s own parser object - `main()` - `shtab`'s own CLI application Given that the number of completions a program may need would likely be less than a million, the focus is on readability rather than premature speed -optimisations. +optimisations. The generated code itself, on the other had, should be fast. Helper functions such as `replace_format` allows use of curly braces `{}` in string snippets without clashing between python's `str.format` and shell diff --git a/README.rst b/README.rst index b273049..ffdbc57 100644 --- a/README.rst +++ b/README.rst @@ -83,6 +83,22 @@ First run ``brew install bash-completion``, then add the following to Usage ----- +There are two ways of using ``shtab``: + +- `CLI Usage`_: ``shtab``'s own CLI interface for external applications + + - may not require any code modifications whatsoever + - end-users execute ``shtab your_cli_app.your_parser_object`` + +- `Library Usage`_: as a library integrated into your CLI application + + - adds a couple of lines to your application + - argument mode: end-users execute ``your_cli_app --print-completion {bash,zsh}`` + - subparser mode: end-users execute ``your_cli_app completion {bash,zsh}`` + +CLI Usage +--------- + The only requirement is that external CLI applications provide an importable ``argparse.ArgumentParser`` object (or alternatively an importable function which returns a parser object). This may require a trivial code change. @@ -203,19 +219,24 @@ appropriate (e.g. ``$CONDA_PREFIX/etc/conda/activate.d/env_vars.sh``). By default, ``shtab`` will silently do nothing if it cannot import the requested application. Use ``-u, --error-unimportable`` to noisily complain. -Advanced Configuration ----------------------- +Library Usage +------------- See the `examples/ `_ folder for more. Complex projects with subparsers and custom completions for paths matching certain patterns (e.g. ``--file=*.txt``) are fully supported (see +`examples/customcomplete.py `_ +or even `iterative/dvc:command/completion.py `_ for example). Add direct support to scripts for a little more configurability: +argparse +~~~~~~~~ + .. code:: python #!/usr/bin/env python @@ -224,12 +245,7 @@ Add direct support to scripts for a little more configurability: def get_main_parser(): parser = argparse.ArgumentParser(prog="pathcomplete") - parser.add_argument( - "-s", - "--print-completion-shell", - choices=["bash", "zsh"], - help="prints completion script", - ) + shtab.add_argument_to(parser, ["-s", "--print-completion"]) # magic! # file & directory tab complete parser.add_argument("file", nargs="?").complete = shtab.FILE parser.add_argument("--dir", default=".").complete = shtab.DIRECTORY @@ -238,13 +254,7 @@ Add direct support to scripts for a little more configurability: if __name__ == "__main__": parser = get_main_parser() args = parser.parse_args() - - # completion magic - shell = args.print_completion_shell - if shell: - print(shtab.complete(parser, shell=shell)) - else: - print("received =%r --dir=%r" % (args.file, args.dir)) + print("received =%r --dir=%r" % (args.file, args.dir)) docopt ~~~~~~ @@ -262,8 +272,6 @@ object from `docopt `_ syntax: Options: -g, --goodbye : Say "goodbye" (instead of "hello") - -b, --print-bash-completion : Output a bash tab-completion script - -z, --print-zsh-completion : Output a zsh tab-completion script Arguments: : Your name [default: Anon] @@ -272,15 +280,9 @@ object from `docopt `_ syntax: import sys, argopt, shtab # NOQA parser = argopt.argopt(__doc__) + shtab.add_argument_to(parser, ["-s", "--print-completion"]) # magic! if __name__ == "__main__": args = parser.parse_args() - if args.print_bash_completion: - print(shtab.complete(parser, shell="bash")) - sys.exit(0) - if args.print_zsh_completion: - print(shtab.complete(parser, shell="zsh")) - sys.exit(0) - msg = "k thx bai!" if args.goodbye else "hai!" print("{} says '{}' to {}".format(args.me, msg, args.you)) diff --git a/examples/customcomplete.py b/examples/customcomplete.py index e57cda2..f539187 100755 --- a/examples/customcomplete.py +++ b/examples/customcomplete.py @@ -1,6 +1,6 @@ #!/usr/bin/env python """ -`argparse`-based CLI app with custom file completion. +`argparse`-based CLI app with custom file completion as well as subparsers. See `pathcomplete.py` for a more basic version. """ @@ -25,14 +25,24 @@ } -def get_main_parser(): - parser = argparse.ArgumentParser(prog="customcomplete") - parser.add_argument( - "-s", - "--print-completion-shell", - choices=["bash", "zsh"], - help="prints completion script", +def process(args): + print( + "received =%r --input-file=%r --output-name=%r" + % (args.input_txt, args.input_file, args.output_name) ) + + +def get_main_parser(): + main_parser = argparse.ArgumentParser(prog="customcomplete") + subparsers = main_parser.add_subparsers() + # make required (py3.7 API change); vis. https://bugs.python.org/issue16308 + subparsers.required = True + subparsers.dest = "subcommand" + + parser = subparsers.add_parser("completion") + shtab.add_argument_to(parser, "shell") # magic! + + parser = subparsers.add_parser("process") # `*.txt` file tab completion parser.add_argument("input_txt", nargs="?").complete = TXT_FILE # file tab completion builtin shortcut @@ -45,20 +55,11 @@ def get_main_parser(): " accidentally overwriting existing files." ), ).complete = shtab.DIRECTORY # directory tab completion builtin shortcut - return parser + parser.set_defaults(func=process) + return main_parser if __name__ == "__main__": parser = get_main_parser() args = parser.parse_args() - - # completion magic - shell = args.print_completion_shell - if shell: - script = shtab.complete(parser, shell=shell, preamble=PREAMBLE) - print(script) - else: - print( - "received =%r --output-dir=%r --output-name=%r" - % (args.input_txt, args.output_dir, args.output_name) - ) + args.func(args) diff --git a/examples/docopt-greeter.py b/examples/docopt-greeter.py index 8ad05cf..4be0b37 100755 --- a/examples/docopt-greeter.py +++ b/examples/docopt-greeter.py @@ -6,8 +6,6 @@ Options: -g, --goodbye : Say "goodbye" (instead of "hello") - -b, --print-bash-completion : Output a bash tab-completion script - -z, --print-zsh-completion : Output a zsh tab-completion script Arguments: : Your name [default: Anon] @@ -16,14 +14,9 @@ import sys, argopt, shtab # NOQA parser = argopt.argopt(__doc__) +shtab.add_argument_to(parser, ["-s", "--print-completion"]) # magic! if __name__ == "__main__": args = parser.parse_args() - if args.print_bash_completion: - print(shtab.complete(parser, shell="bash")) - sys.exit(0) - if args.print_zsh_completion: - print(shtab.complete(parser, shell="zsh")) - sys.exit(0) msg = "k thx bai!" if args.goodbye else "hai!" print("{} says '{}' to {}".format(args.me, msg, args.you)) diff --git a/examples/pathcomplete.py b/examples/pathcomplete.py index 749aaad..ad12873 100755 --- a/examples/pathcomplete.py +++ b/examples/pathcomplete.py @@ -12,12 +12,7 @@ def get_main_parser(): parser = argparse.ArgumentParser(prog="pathcomplete") - parser.add_argument( - "-s", - "--print-completion-shell", - choices=["bash", "zsh"], - help="prints completion script", - ) + shtab.add_argument_to(parser, ["-s", "--print-completion"]) # magic! # file & directory tab complete parser.add_argument("file", nargs="?").complete = shtab.FILE parser.add_argument("--dir", default=".").complete = shtab.DIRECTORY @@ -27,10 +22,4 @@ def get_main_parser(): if __name__ == "__main__": parser = get_main_parser() args = parser.parse_args() - - # completion magic - shell = args.print_completion_shell - if shell: - print(shtab.complete(parser, shell=shell)) - else: - print("received =%r --dir=%r" % (args.file, args.dir)) + print("received =%r --dir=%r" % (args.file, args.dir)) diff --git a/shtab/__init__.py b/shtab/__init__.py index a0125ec..a26846c 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -3,8 +3,10 @@ import io import logging import re +import sys from argparse import ( SUPPRESS, + Action, _AppendAction, _AppendConstAction, _CountAction, @@ -39,9 +41,18 @@ def get_version_dist(name=__name__): __version__ = get_version(root="..", relative_to=__file__) except LookupError: __version__ = get_version_dist() -__all__ = ["Optional", "Required", "Choice", "complete"] +__all__ = [ + "complete", + "add_argument_to", + "SUPPORTED_SHELLS", + "FILE", + "DIRECTORY", + "DIR", +] log = logging.getLogger(__name__) +SUPPORTED_SHELLS = [] +_SUPPORTED_COMPLETERS = {} CHOICE_FUNCTIONS = { "file": {"bash": "_shtab_compgen_files", "zsh": "_files"}, "directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/"}, @@ -60,6 +71,25 @@ def get_version_dist(name=__name__): RE_ZSH_SPECIAL_CHARS = re.compile(r"([^\w\s.,()-])") # excessive but safe +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): + try: + return _SUPPORTED_COMPLETERS[shell] + except KeyError: + raise NotImplementedError( + "shell (%s) must be in {%s}" % (shell, ",".join(SUPPORTED_SHELLS)) + ) + + @total_ordering class Choice(object): """ @@ -122,6 +152,11 @@ def replace_format(string, **fmt): return string +def wordify(string): + """Replace hyphens (-) and spaces ( ) with underscores (_)""" + return string.replace("-", "_").replace(" ", "_") + + def get_bash_commands(root_parser, root_prefix, choice_functions=None): """ Recursive subcommand parser traversal, printing bash helper syntax. @@ -176,21 +211,19 @@ def recurse(parser, prefix): opts += get_optional_actions(parser) # use list rather than set to maintain order opts = " ".join(opts) - print("{}='{}'".format(prefix, opts), file=fd) + print(u"{}='{}'".format(prefix, opts), file=fd) for sub in positionals: + if hasattr(sub, "complete"): + print( + u"{}_COMPGEN={}".format( + prefix, + complete2pattern(sub.complete, "bash", choice_type2fn), + ), + file=fd, + ) if sub.choices: log.debug("choices:{}:{}".format(prefix, sorted(sub.choices))) - if hasattr(sub, "complete"): - print( - "{}_COMPGEN={}".format( - prefix, - complete2pattern( - sub.complete, "bash", choice_type2fn - ), - ), - file=fd, - ) for cmd in sorted(sub.choices): if isinstance(cmd, Choice): log.debug( @@ -199,7 +232,7 @@ def recurse(parser, prefix): ) ) print( - "{}_COMPGEN={}".format( + u"{}_COMPGEN={}".format( prefix, choice_type2fn[cmd.type] ), file=fd, @@ -209,8 +242,7 @@ def recurse(parser, prefix): if sub.choices[cmd].add_help: commands.append(cmd) recurse( - sub.choices[cmd], - prefix + "_" + cmd.replace("-", "_"), + sub.choices[cmd], prefix + "_" + wordify(cmd), ) else: log.debug("skip:subcommand:%s", cmd) @@ -226,6 +258,7 @@ def recurse(parser, prefix): return recurse(root_parser, root_prefix), root_options, fd.getvalue() +@mark_completer("bash") def complete_bash( parser, root_prefix=None, preamble="", choice_functions=None, ): @@ -234,7 +267,7 @@ def complete_bash( See `complete` for arguments. """ - root_prefix = "_shtab_" + (root_prefix or parser.prog) + root_prefix = wordify("_shtab_" + (root_prefix or parser.prog)) commands, options, subcommands_script = get_bash_commands( parser, root_prefix, choice_functions=choice_functions ) @@ -347,14 +380,14 @@ def escape_zsh(string): return RE_ZSH_SPECIAL_CHARS.sub(r"\\\1", string) +@mark_completer("zsh") def complete_zsh(parser, root_prefix=None, preamble="", choice_functions=None): """ Returns zsh syntax autocompletion script. See `complete` for arguments. """ - root_prefix = "_shtab_" + (root_prefix or parser.prog) - + root_prefix = wordify("_shtab_" + (root_prefix or parser.prog)) root_arguments = [] subcommands = {} # {cmd: {"help": help, "arguments": [arguments]}} @@ -516,9 +549,7 @@ def format_positional(opt): ), commands_case="\n ".join( "{cmd_orig}) _arguments ${root_prefix}_{cmd} ;;".format( - cmd_orig=cmd, - cmd=cmd.replace("-", "_"), - root_prefix=root_prefix, + cmd_orig=cmd, cmd=wordify(cmd), root_prefix=root_prefix, ) for cmd in sorted(subcommands) ), @@ -528,7 +559,7 @@ def format_positional(opt): {arguments} )""".format( root_prefix=root_prefix, - cmd=cmd.replace("-", "_"), + cmd=wordify(cmd), arguments="\n ".join(subcommands[cmd]["arguments"]), ) for cmd in sorted(subcommands) @@ -558,18 +589,43 @@ def complete( """ if isinstance(preamble, dict): preamble = preamble.get(shell, "") - if shell == "bash": - return complete_bash( - parser, - root_prefix=root_prefix, - preamble=preamble, - choice_functions=choice_functions, - ) - if shell == "zsh": - return complete_zsh( - parser, - root_prefix=root_prefix, - preamble=preamble, - choice_functions=choice_functions, - ) - raise NotImplementedError(shell) + completer = get_completer(shell) + return completer( + parser, + root_prefix=root_prefix, + preamble=preamble, + choice_functions=choice_functions, + ) + + +class PrintCompletionAction(Action): + def __call__(self, parser, namespace, values, option_string=None): + print(complete(parser, values)) + parser.exit(0) + + +def add_argument_to( + parser, + option_string="--print-completion", + help="print shell completion script", +): + """ + parser : argparse.ArgumentParser + option_string : str or list[str], iff positional (no `-` prefix) then + `parser` is assumed to actually be a subparser (subcommand mode) + help : str + """ + if isinstance( + option_string, str if sys.version_info[0] > 2 else basestring # NOQA + ): + option_string = [option_string] + kwargs = dict( + choices=SUPPORTED_SHELLS, + default=None, + help=help, + action=PrintCompletionAction, + ) + if option_string[0][0] != "-": # subparser mode + kwargs.update(default=SUPPORTED_SHELLS[0], nargs="?") + parser.add_argument(*option_string, **kwargs) + return parser diff --git a/shtab/main.py b/shtab/main.py index 5daf77c..b8126f4 100644 --- a/shtab/main.py +++ b/shtab/main.py @@ -6,7 +6,7 @@ import sys from importlib import import_module -from . import __version__, complete +from . import SUPPORTED_SHELLS, __version__, complete log = logging.getLogger(__name__) @@ -20,7 +20,7 @@ def get_main_parser(): "--version", action="version", version="%(prog)s " + __version__ ) parser.add_argument( - "-s", "--shell", default="bash", choices=["bash", "zsh"] + "-s", "--shell", default=SUPPORTED_SHELLS[0], choices=SUPPORTED_SHELLS ) parser.add_argument( "--prefix", help="prepended to generated functions to avoid clashes" diff --git a/tests/test_shtab.py b/tests/test_shtab.py index 9e24dbf..21a7aae 100644 --- a/tests/test_shtab.py +++ b/tests/test_shtab.py @@ -13,8 +13,7 @@ import shtab from shtab.main import get_main_parser, main -SUPPORTED_SHELLS = "bash", "zsh" -fix_shell = pytest.mark.parametrize("shell", SUPPORTED_SHELLS) +fix_shell = pytest.mark.parametrize("shell", shtab.SUPPORTED_SHELLS) class Bash(object): @@ -133,3 +132,89 @@ def test_positional_choices(shell, caplog): shell.compgen('-W "$_shtab_test_commands_"', "o", "one") assert not caplog.record_tuples + + +@fix_shell +def test_custom_complete(shell, caplog): + parser = ArgumentParser(prog="test") + parser.add_argument("posA").complete = {"bash": "_shtab_test_some_func"} + preamble = { + "bash": "_shtab_test_some_func() { compgen -W 'one two' -- $1 ;}" + } + with caplog.at_level(logging.INFO): + completion = shtab.complete(parser, shell=shell, preamble=preamble) + print(completion) + + if shell == "bash": + shell = Bash(completion) + shell.test('"$($_shtab_test_COMPGEN o)" = "one"') + + assert not caplog.record_tuples + + +@fix_shell +def test_subparser_custom_complete(shell, caplog): + parser = ArgumentParser(prog="test") + subparsers = parser.add_subparsers() + sub = subparsers.add_parser("sub") + sub.add_argument("posA").complete = {"bash": "_shtab_test_some_func"} + preamble = { + "bash": "_shtab_test_some_func() { compgen -W 'one two' -- $1 ;}" + } + with caplog.at_level(logging.INFO): + completion = shtab.complete(parser, shell=shell, preamble=preamble) + print(completion) + + if shell == "bash": + shell = Bash(completion) + shell.test('"$($_shtab_test_sub_COMPGEN o)" = "one"') + shell.test('-z "$($_shtab_test_COMPGEN o)"') + + assert not caplog.record_tuples + + +@fix_shell +def test_add_argument_to_optional(shell, caplog): + parser = ArgumentParser(prog="test") + shtab.add_argument_to(parser, ["-s", "--shell"]) + with caplog.at_level(logging.INFO): + completion = shtab.complete(parser, shell=shell) + print(completion) + + if shell == "bash": + shell = Bash(completion) + shell.compgen('-W "$_shtab_test_options_"', "--s", "--shell") + + assert not caplog.record_tuples + + +@fix_shell +def test_add_argument_to_positional(shell, caplog): + parser = ArgumentParser(prog="test") + subparsers = parser.add_subparsers() + sub = subparsers.add_parser("completion") + shtab.add_argument_to(sub, "shell") + with caplog.at_level(logging.INFO): + completion = shtab.complete(parser, shell=shell) + print(completion) + + if shell == "bash": + shell = Bash(completion) + shell.compgen('-W "$_shtab_test_completion"', "ba", "bash") + shell.compgen('-W "$_shtab_test_completion"', "z", "zsh") + + assert not caplog.record_tuples + + +@fix_shell +def test_get_completer(shell): + shtab.get_completer(shell) + + +def test_get_completer_invalid(): + try: + shtab.get_completer("invalid") + except NotImplementedError: + pass + else: + raise NotImplementedError("invalid")