From 2207c23818cf17eb1d4e8c7bf24776ae638bafed Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 1 Aug 2020 16:24:33 +0100 Subject: [PATCH 01/17] complete: slight tidy inspired by https://www.reddit.com/r/Python/comments/hyyl30/weve_release_an_open_source_lib_to_generate_tab/fzhj3uv/ --- shtab/__init__.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index a0125ec..efd2160 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -558,18 +558,15 @@ 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, - ) + completers = {"bash": complete_bash, "zsh": complete_zsh} + try: + driver = completers[shell] + except KeyError: + raise KeyError("shell must be one of {%s}" % ",".join(completers)) + return driver( + parser, + root_prefix=root_prefix, + preamble=preamble, + choice_functions=choice_functions, + ) raise NotImplementedError(shell) From 7d17f7186e0d103d88caf0fb8b2a373c410fc0ad Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 1 Aug 2020 16:37:52 +0100 Subject: [PATCH 02/17] expose and use `SUPPORTED_SHELLS` --- README.rst | 2 +- examples/customcomplete.py | 2 +- examples/pathcomplete.py | 2 +- shtab/__init__.py | 9 ++++++--- shtab/main.py | 4 ++-- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index b273049..34c526f 100644 --- a/README.rst +++ b/README.rst @@ -227,7 +227,7 @@ Add direct support to scripts for a little more configurability: parser.add_argument( "-s", "--print-completion-shell", - choices=["bash", "zsh"], + choices=shtab.SUPPORTED_SHELLS, help="prints completion script", ) # file & directory tab complete diff --git a/examples/customcomplete.py b/examples/customcomplete.py index e57cda2..58b6686 100755 --- a/examples/customcomplete.py +++ b/examples/customcomplete.py @@ -30,7 +30,7 @@ def get_main_parser(): parser.add_argument( "-s", "--print-completion-shell", - choices=["bash", "zsh"], + choices=shtab.SUPPORTED_SHELLS, help="prints completion script", ) # `*.txt` file tab completion diff --git a/examples/pathcomplete.py b/examples/pathcomplete.py index 749aaad..d9cef19 100755 --- a/examples/pathcomplete.py +++ b/examples/pathcomplete.py @@ -15,7 +15,7 @@ def get_main_parser(): parser.add_argument( "-s", "--print-completion-shell", - choices=["bash", "zsh"], + choices=shtab.SUPPORTED_SHELLS, help="prints completion script", ) # file & directory tab complete diff --git a/shtab/__init__.py b/shtab/__init__.py index efd2160..173b72e 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -42,6 +42,7 @@ def get_version_dist(name=__name__): __all__ = ["Optional", "Required", "Choice", "complete"] log = logging.getLogger(__name__) +SUPPORTED_SHELLS = ("bash", "zsh") CHOICE_FUNCTIONS = { "file": {"bash": "_shtab_compgen_files", "zsh": "_files"}, "directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/"}, @@ -558,11 +559,13 @@ def complete( """ if isinstance(preamble, dict): preamble = preamble.get(shell, "") - completers = {"bash": complete_bash, "zsh": complete_zsh} try: - driver = completers[shell] + driver = globals()["complete_" + shell] except KeyError: - raise KeyError("shell must be one of {%s}" % ",".join(completers)) + raise KeyError( + "shell (%s) must be one of {%s}" + % (shell, ",".join(SUPPORTED_SHELLS)) + ) return driver( parser, root_prefix=root_prefix, 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" From a4fce485dc00ac60d7148a35936e9c31b1d93c43 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 1 Aug 2020 17:59:30 +0100 Subject: [PATCH 03/17] add `add_argument_to` - fixes #18 --- shtab/__init__.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/shtab/__init__.py b/shtab/__init__.py index 173b72e..a718a14 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, @@ -573,3 +575,33 @@ def complete( choice_functions=choice_functions, ) raise NotImplementedError(shell) + + +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-shell", + help="print shell completion script", +): + """ + parser : argparse.ArgumentParser + option_string : str or list[str] + 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, + ) + parser.add_argument(*option_string, **kwargs) + return parser From 5c6a63a6010c75b2bfcbc411d0f6c3a53ae2bf2f Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 1 Aug 2020 18:06:14 +0100 Subject: [PATCH 04/17] document `add_argument_to` --- README.rst | 17 ++--------------- examples/customcomplete.py | 22 +++++----------------- examples/docopt-greeter.py | 9 +-------- examples/pathcomplete.py | 15 ++------------- 4 files changed, 10 insertions(+), 53 deletions(-) diff --git a/README.rst b/README.rst index 34c526f..4c7cae3 100644 --- a/README.rst +++ b/README.rst @@ -224,12 +224,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=shtab.SUPPORTED_SHELLS, - help="prints completion script", - ) + shtab.add_argument_to(parser, ["-s", "--print-completion-shell"]) # magic! # file & directory tab complete parser.add_argument("file", nargs="?").complete = shtab.FILE parser.add_argument("--dir", default=".").complete = shtab.DIRECTORY @@ -262,8 +257,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 +265,9 @@ object from `docopt `_ syntax: import sys, argopt, shtab # NOQA parser = argopt.argopt(__doc__) + shtab.add_argument_to(parser, ["-s", "--print-completion-shell"]) # 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 58b6686..058391a 100755 --- a/examples/customcomplete.py +++ b/examples/customcomplete.py @@ -27,12 +27,7 @@ def get_main_parser(): parser = argparse.ArgumentParser(prog="customcomplete") - parser.add_argument( - "-s", - "--print-completion-shell", - choices=shtab.SUPPORTED_SHELLS, - help="prints completion script", - ) + shtab.add_argument_to(parser, ["-s", "--print-completion-shell"]) # magic! # `*.txt` file tab completion parser.add_argument("input_txt", nargs="?").complete = TXT_FILE # file tab completion builtin shortcut @@ -51,14 +46,7 @@ def get_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) - ) + print( + "received =%r --input-file=%r --output-name=%r" + % (args.input_txt, args.input_file, args.output_name) + ) diff --git a/examples/docopt-greeter.py b/examples/docopt-greeter.py index 8ad05cf..691c51a 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-shell"]) # 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 d9cef19..1f7a6a4 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=shtab.SUPPORTED_SHELLS, - help="prints completion script", - ) + shtab.add_argument_to(parser, ["-s", "--print-completion-shell"]) # 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)) From a60266a532af70a500ef384b9ad7fb00658dc680 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 1 Aug 2020 18:08:48 +0100 Subject: [PATCH 05/17] tidy error handling --- shtab/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index a718a14..248ea12 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -564,9 +564,8 @@ def complete( try: driver = globals()["complete_" + shell] except KeyError: - raise KeyError( - "shell (%s) must be one of {%s}" - % (shell, ",".join(SUPPORTED_SHELLS)) + raise NotImplementedError( + "shell (%s) must be in {%s}" % (shell, ",".join(SUPPORTED_SHELLS)) ) return driver( parser, @@ -574,7 +573,6 @@ def complete( preamble=preamble, choice_functions=choice_functions, ) - raise NotImplementedError(shell) class PrintCompletionAction(Action): From 51f131c263f45a204edc0940fc5a4ea220921ec0 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 1 Aug 2020 22:20:55 +0100 Subject: [PATCH 06/17] add subparsers/subcommand example, update README --- CONTRIBUTING.md | 2 +- README.rst | 25 +++++++++++++++++++++++-- examples/customcomplete.py | 13 +++++++++---- shtab/__init__.py | 5 ++++- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54b47b7..71e66ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ Most of the magic lives in [`shtab/__init__.py`](./shtab/__init__.py). 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 4c7cae3..5438a65 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-shell {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 diff --git a/examples/customcomplete.py b/examples/customcomplete.py index 058391a..6ef9781 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. """ @@ -26,8 +26,13 @@ def get_main_parser(): - parser = argparse.ArgumentParser(prog="customcomplete") - shtab.add_argument_to(parser, ["-s", "--print-completion-shell"]) # magic! + main_parser = argparse.ArgumentParser(prog="customcomplete") + subparsers = main_parser.add_subparsers() + + 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 @@ -40,7 +45,7 @@ def get_main_parser(): " accidentally overwriting existing files." ), ).complete = shtab.DIRECTORY # directory tab completion builtin shortcut - return parser + return main_parser if __name__ == "__main__": diff --git a/shtab/__init__.py b/shtab/__init__.py index 248ea12..b52cb44 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -588,7 +588,8 @@ def add_argument_to( ): """ parser : argparse.ArgumentParser - option_string : str or list[str] + 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( @@ -601,5 +602,7 @@ def add_argument_to( 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 From 80b5027d653c04b152cf59a2a6af1997d1c13ce1 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 1 Aug 2020 22:40:49 +0100 Subject: [PATCH 07/17] SUPPORTED_SHELLS: use a registry model - addresses https://github.com/iterative/shtab/pull/19#discussion_r463984739 --- shtab/__init__.py | 40 ++++++++++++++++++++++++++++++++-------- tests/test_shtab.py | 3 +-- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index b52cb44..68e3537 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -41,10 +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 = ("bash", "zsh") +SUPPORTED_SHELLS = [] +_SUPPORTED_COMPLETERS = {} CHOICE_FUNCTIONS = { "file": {"bash": "_shtab_compgen_files", "zsh": "_files"}, "directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/"}, @@ -63,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): """ @@ -229,6 +256,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, ): @@ -350,6 +378,7 @@ 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. @@ -561,12 +590,7 @@ def complete( """ if isinstance(preamble, dict): preamble = preamble.get(shell, "") - try: - driver = globals()["complete_" + shell] - except KeyError: - raise NotImplementedError( - "shell (%s) must be in {%s}" % (shell, ",".join(SUPPORTED_SHELLS)) - ) + driver = get_completer(shell) return driver( parser, root_prefix=root_prefix, diff --git a/tests/test_shtab.py b/tests/test_shtab.py index 9e24dbf..9684b18 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): From eeb18bc530acf2c2b74881fa5ea55eb0eb043aa3 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 1 Aug 2020 22:49:47 +0100 Subject: [PATCH 08/17] --print-completion-shell -> --print-completion --- README.rst | 6 +++--- examples/docopt-greeter.py | 2 +- examples/pathcomplete.py | 2 +- shtab/__init__.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 5438a65..92d4cc3 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ There are two ways of using ``shtab``: - `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-shell {bash,zsh}`` + - 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 @@ -245,7 +245,7 @@ argparse def get_main_parser(): parser = argparse.ArgumentParser(prog="pathcomplete") - shtab.add_argument_to(parser, ["-s", "--print-completion-shell"]) # magic! + 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 @@ -286,7 +286,7 @@ object from `docopt `_ syntax: import sys, argopt, shtab # NOQA parser = argopt.argopt(__doc__) - shtab.add_argument_to(parser, ["-s", "--print-completion-shell"]) # magic! + shtab.add_argument_to(parser, ["-s", "--print-completion"]) # magic! if __name__ == "__main__": args = parser.parse_args() msg = "k thx bai!" if args.goodbye else "hai!" diff --git a/examples/docopt-greeter.py b/examples/docopt-greeter.py index 691c51a..4be0b37 100755 --- a/examples/docopt-greeter.py +++ b/examples/docopt-greeter.py @@ -14,7 +14,7 @@ import sys, argopt, shtab # NOQA parser = argopt.argopt(__doc__) -shtab.add_argument_to(parser, ["-s", "--print-completion-shell"]) # magic! +shtab.add_argument_to(parser, ["-s", "--print-completion"]) # magic! if __name__ == "__main__": args = parser.parse_args() diff --git a/examples/pathcomplete.py b/examples/pathcomplete.py index 1f7a6a4..ad12873 100755 --- a/examples/pathcomplete.py +++ b/examples/pathcomplete.py @@ -12,7 +12,7 @@ def get_main_parser(): parser = argparse.ArgumentParser(prog="pathcomplete") - shtab.add_argument_to(parser, ["-s", "--print-completion-shell"]) # magic! + 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 diff --git a/shtab/__init__.py b/shtab/__init__.py index 68e3537..813754e 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -607,7 +607,7 @@ def __call__(self, parser, namespace, values, option_string=None): def add_argument_to( parser, - option_string="--print-completion-shell", + option_string="--print-completion", help="print shell completion script", ): """ From b29766d59759bc6ae79c45f89d3cd5ce24491eac Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Sat, 1 Aug 2020 22:58:20 +0100 Subject: [PATCH 09/17] minor CONTRIBUTING update and linting --- CONTRIBUTING.md | 4 ++-- shtab/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71e66ea..47df910 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,8 +20,8 @@ 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 diff --git a/shtab/__init__.py b/shtab/__init__.py index 813754e..4596f75 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -590,8 +590,8 @@ def complete( """ if isinstance(preamble, dict): preamble = preamble.get(shell, "") - driver = get_completer(shell) - return driver( + completer = get_completer(shell) + return completer( parser, root_prefix=root_prefix, preamble=preamble, From 4a8aa7d88ae265fe53d9e1cc2a3df5b0257aa1eb Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Tue, 4 Aug 2020 00:50:39 +0100 Subject: [PATCH 10/17] bash: fix missing complete functionality --- shtab/__init__.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 4596f75..fecb731 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -209,18 +209,16 @@ def recurse(parser, prefix): print("{}='{}'".format(prefix, opts), file=fd) for sub in positionals: + if hasattr(sub, "complete"): + print( + "{}_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( From 25b6d03a0a69acb76349eb3004edcf9fac3db564 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Tue, 4 Aug 2020 00:53:20 +0100 Subject: [PATCH 11/17] test: get_completer --- tests/test_shtab.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_shtab.py b/tests/test_shtab.py index 9684b18..87fc0ac 100644 --- a/tests/test_shtab.py +++ b/tests/test_shtab.py @@ -132,3 +132,12 @@ def test_positional_choices(shell, caplog): shell.compgen('-W "$_shtab_test_commands_"', "o", "one") assert not caplog.record_tuples + + +def test_get_completer(): + try: + shtab.get_completer("invalid") + except NotImplementedError: + pass + else: + raise NotImplementedError("invalid") From 62aa543e7ac30425247fb59e781dae2d5ce8fef6 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Tue, 4 Aug 2020 01:16:42 +0100 Subject: [PATCH 12/17] test: positional custom completion --- tests/test_shtab.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_shtab.py b/tests/test_shtab.py index 87fc0ac..23b6cc9 100644 --- a/tests/test_shtab.py +++ b/tests/test_shtab.py @@ -134,6 +134,45 @@ def test_positional_choices(shell, caplog): 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 + + def test_get_completer(): try: shtab.get_completer("invalid") From 7e899e1edf86a5ff24eb177e96f9198ff04a96f4 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Tue, 4 Aug 2020 01:28:52 +0100 Subject: [PATCH 13/17] fix py2 tests --- shtab/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index fecb731..de38622 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -206,12 +206,12 @@ 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( - "{}_COMPGEN={}".format( + u"{}_COMPGEN={}".format( prefix, complete2pattern(sub.complete, "bash", choice_type2fn), ), @@ -227,7 +227,7 @@ def recurse(parser, prefix): ) ) print( - "{}_COMPGEN={}".format( + u"{}_COMPGEN={}".format( prefix, choice_type2fn[cmd.type] ), file=fd, From fb2136c4013358a609878b9b566dfd9ec47755ae Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Tue, 4 Aug 2020 01:43:42 +0100 Subject: [PATCH 14/17] minor test split --- tests/test_shtab.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_shtab.py b/tests/test_shtab.py index 23b6cc9..a5a5e16 100644 --- a/tests/test_shtab.py +++ b/tests/test_shtab.py @@ -173,7 +173,12 @@ def test_subparser_custom_complete(shell, caplog): assert not caplog.record_tuples -def test_get_completer(): +@fix_shell +def test_get_completer(shell): + shtab.get_completer(shell) + + +def test_get_completer_invalid(): try: shtab.get_completer("invalid") except NotImplementedError: From 9a71a049f2e2d983a5bf9def5eedebf6974b9661 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Tue, 4 Aug 2020 01:44:05 +0100 Subject: [PATCH 15/17] tests: add_argument_to --- tests/test_shtab.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_shtab.py b/tests/test_shtab.py index a5a5e16..21a7aae 100644 --- a/tests/test_shtab.py +++ b/tests/test_shtab.py @@ -173,6 +173,39 @@ def test_subparser_custom_complete(shell, caplog): 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) From 386f089790e76e5a2de6d04bb5d13fb2d5946aef Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Tue, 4 Aug 2020 02:24:03 +0100 Subject: [PATCH 16/17] fix & sync examples --- README.rst | 8 +------- examples/customcomplete.py | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/README.rst b/README.rst index 92d4cc3..ffdbc57 100644 --- a/README.rst +++ b/README.rst @@ -254,13 +254,7 @@ argparse 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 ~~~~~~ diff --git a/examples/customcomplete.py b/examples/customcomplete.py index 6ef9781..d4edc95 100755 --- a/examples/customcomplete.py +++ b/examples/customcomplete.py @@ -5,6 +5,8 @@ See `pathcomplete.py` for a more basic version. """ import argparse +import functools +import sys import shtab # for completion magic @@ -25,9 +27,22 @@ } +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() + add_subparsers = functools.partial( + main_parser.add_subparsers, dest="subcommand" + ) + if sys.version_info[:2] >= (3, 7): + subparsers = add_subparsers(required=True) + else: + subparsers = add_subparsers() parser = subparsers.add_parser("completion") shtab.add_argument_to(parser, "shell") # magic! @@ -45,13 +60,11 @@ def get_main_parser(): " accidentally overwriting existing files." ), ).complete = shtab.DIRECTORY # directory tab completion builtin shortcut + parser.set_defaults(func=process) return main_parser if __name__ == "__main__": parser = get_main_parser() args = parser.parse_args() - print( - "received =%r --input-file=%r --output-name=%r" - % (args.input_txt, args.input_file, args.output_name) - ) + args.func(args) From 3018fc76f53451926fc01b7592b6b12f271b8537 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Tue, 4 Aug 2020 18:57:02 +0100 Subject: [PATCH 17/17] fix subcommands with spaces & hyphens - update & fix examples/customcomplete.py --- examples/customcomplete.py | 13 ++++--------- shtab/__init__.py | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/examples/customcomplete.py b/examples/customcomplete.py index d4edc95..f539187 100755 --- a/examples/customcomplete.py +++ b/examples/customcomplete.py @@ -5,8 +5,6 @@ See `pathcomplete.py` for a more basic version. """ import argparse -import functools -import sys import shtab # for completion magic @@ -36,13 +34,10 @@ def process(args): def get_main_parser(): main_parser = argparse.ArgumentParser(prog="customcomplete") - add_subparsers = functools.partial( - main_parser.add_subparsers, dest="subcommand" - ) - if sys.version_info[:2] >= (3, 7): - subparsers = add_subparsers(required=True) - else: - subparsers = add_subparsers() + 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! diff --git a/shtab/__init__.py b/shtab/__init__.py index de38622..a26846c 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -152,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. @@ -237,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) @@ -263,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 ) @@ -383,8 +387,7 @@ def complete_zsh(parser, root_prefix=None, preamble="", choice_functions=None): 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]}} @@ -546,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) ), @@ -558,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)