Skip to content

Instantly share code, notes, and snippets.

@zeroSteiner
Created April 2, 2021 00:23
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save zeroSteiner/215953f2fe54ed75e8159125fabe2aba to your computer and use it in GitHub Desktop.
Save zeroSteiner/215953f2fe54ed75e8159125fabe2aba to your computer and use it in GitHub Desktop.
ZSH completion from argparse
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# zpycompletion
#
# Copyright 2015 Spencer McIntyre <zeroSteiner@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following disclaimer
# in the documentation and/or other materials provided with the
# distribution.
# * Neither the name of the nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
import argparse
import contextlib
import datetime
import imp
import os
import pwd
import sys
import jinja2
__all__ = ['make_completion_from_argparse']
__version__ = '1.2'
if sys.version_info[0] == 2:
input = raw_input
# script.arguments
# script.author
# script.c_year
# script.name
# script.subparser_cmds
# script.subparsers
# version
ZSH_COMPLETION_TEMPLATE = """\
#compdef {{ script.name }}
# ------------------------------------------------------------------------------
# Copyright (c) {{ script.c_year }} {{ script.author }}
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the project nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL ZSH-USERS BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Description
# -----------
#
# Completion script for {{ script.name }}.
#
# ------------------------------------------------------------------------------
# Authors
# -------
#
# * {{ script.author }}
#
# ------------------------------------------------------------------------------
# Generated with zpycompletion v{{ version }}
{% for subparser_cmd in script.subparser_cmds %}
{{ subparser_cmd.name }}() {
_arguments \\
{% for line in subparser_cmd.arguments %}
{{ line }}
{% endfor %}
}
{% endfor %}
{% for subparser in script.subparsers %}
{{ subparser.name }}() {
local -a _subparser_cmds
_subparser_cmds=(
{% for action in subparser.actions %}
"{{ action.name }}:{{ action.help }}"
{% endfor %}
)
if (( CURRENT == {{ subparser.position }} )); then
_describe "commands" _subparser_cmds
else
local curcontext="$curcontext"
cmd="${${_subparser_cmds[(r)$words[{{ subparser.position }}]:*]%%:*}}"
if (( $#cmd )); then
if (( $+functions[{{ subparser.name }}_cmd_$cmd] )); then
{% if subparser.position > 1 -%}
(( CURRENT -= {{ subparser.position - 1 }} ))
shift {{ subparser.position - 1 }} words
{% endif -%}
{{ subparser.name }}_cmd_$cmd CURRENT
else
_files
fi
else
_message "unknown command: $words[{{ subparser.position }}]"
fi
fi
}
{% endfor %}
_arguments \\
{% for line in script.arguments %}
{{ line }}
{% endfor %}
"""
def _actions_sort(action):
if len(action.option_strings):
return sorted(action.option_strings)[-1]
return ''
def _argument_action(action):
zarg_action = ''
if action.choices:
zarg_action = ':(' + ' '.join(action.choices) + ')'
elif isinstance(action.type, argparse.FileType):
zarg_action = ':_files'
return zarg_action
class _ZshCompletion(object):
def __init__(self, parser):
self.parser = parser
self.arguments = []
self.subparsers = []
self.subparser_cmds = []
actions = sorted(self.parser._actions, key=_actions_sort)
self.arguments = self._parse_actions(actions)
def _parse_actions(self, actions, subparser_name=None):
lines = []
subparser = None
positional = 1
for action in actions:
if isinstance(action, argparse._SubParsersAction):
subparser = (action, positional)
continue
if isinstance(action, argparse._HelpAction):
lines.append("{-h,--help}\"[show help text]\"")
continue
if len(action.option_strings) == 0: # positional
if isinstance(action.nargs, int) and action.nargs > 1:
for _ in range(positional, (positional + action.nargs)):
lines.append("\"{0}::{1}{2}\"".format(positional, action.dest, _argument_action(action)))
positional += 1
continue
elif action.nargs in (argparse.REMAINDER, '*', '+'):
line = '"*'
elif action.nargs in (None, 1, '?'):
line = '"' + str(positional)
positional += 1
line += ':' + action.dest
else:
if len(action.option_strings) == 1:
options = '"' + action.option_strings[0]
else:
options = '{' + ','.join(action.option_strings) + '}"'
if isinstance(action, argparse._AppendAction):
line = '"*"' + options + "[{0}]:{1}".format(action.help, action.dest.replace('_', ' '))
elif isinstance(action, argparse._StoreAction):
line = options + "[{0}]:{1}".format(action.help, action.dest.replace('_', ' '))
elif isinstance(action, argparse._VersionAction):
line = options + '[show version information]'
elif isinstance(action, argparse._StoreConstAction):
line = options + '[' + action.help + ']'
else:
continue
line += _argument_action(action) + '"'
lines.append(line)
if subparser:
subparser, position = subparser
subp_actions = map(lambda a: dict(name=a.dest, help=a.help), subparser._choices_actions)
subp_dest = ('action' if subparser.dest == argparse.SUPPRESS else subparser.dest)
subp_name = (subparser_name or '_subparser') + '_' + subp_dest
self.subparsers.append(dict(name=subp_name, position=position, actions=subp_actions))
lines.append("\"*::{0}:{1}\"".format(subp_dest, subp_name))
for key, value in subparser._name_parser_map.items():
subp_cmd_name = "{0}_cmd_{1}".format(subp_name, key)
subp_cmd_arguments = self._parse_actions(value._actions, subparser_name=subp_name)
self.subparser_cmds.append(dict(name=subp_cmd_name, arguments=subp_cmd_arguments))
for i in range(len(lines) - 1):
lines[i] = lines[i] + ' \\'
return lines
def make_completion_from_argparse(parser, destination=None, input_prompt=True):
"""
Create a zsh completion file from a :py:class:`argparse.ArgumentParser`
instance.
:param parser: The parser instance to build completions for.
:type parser: :py:class:`argparse.ArgumentParser`
:param bool input_prompt: Whether to prompt for user input or not.
"""
script = {}
script['author'] = pwd.getpwuid(os.getuid()).pw_gecos
if input_prompt:
script['author'] = input("[?] author ({0}): ".format(script['author'])) or script['author']
script['c_year'] = datetime.date.today().year
if input_prompt:
script['c_year'] = input("[?] copyright year ({0}): ".format(script['c_year'])) or script['c_year']
script['name'] = parser.prog
if input_prompt:
script['name'] = input("[?] script name ({0}): ".format(script['name'])) or script['name']
zsh_comp = _ZshCompletion(parser)
script['arguments'] = zsh_comp.arguments
script['subparsers'] = zsh_comp.subparsers
script['subparser_cmds'] = zsh_comp.subparser_cmds
env = jinja2.Environment(trim_blocks=True)
template = env.from_string(ZSH_COMPLETION_TEMPLATE)
destination = destination or '_' + script['name']
with open(destination, 'w') as file_h:
file_h.write(template.render(script=script, version=__version__))
print('[*] completion saved as: ' + destination)
class _FakeArgparse(object):
def __init__(self):
self.parser = None
def __getattr__(self, name):
if name == 'ArgumentParser':
return self._hook
return getattr(argparse, name)
def _hook(self, *args, **kwargs):
self.parser = argparse.ArgumentParser(*args, **kwargs)
return self.parser
@contextlib.contextmanager
def _muted_std_streams():
stdout = sys.stdout
stderr = sys.stderr
sys.stdout = open(os.devnull, 'w')
sys.stderr = open(os.devnull, 'w')
try:
yield
except Exception:
raise
finally:
sys.stdout = stdout
sys.stderr = stderr
def main():
parser = argparse.ArgumentParser()
parser.add_argument('-m', '--method', dest='target_function', default='main', help='the function which creates the ArgumentParser instance')
parser.add_argument('-o', '--output', dest='output', help='the path to write the completion file to')
parser.add_argument('--no-prompt', dest='input_prompt', default=True, action='store_false', help='do not prompt for input')
parser.add_argument('script', help='the python script to load from')
arguments = parser.parse_args()
script = os.path.abspath(arguments.script)
if not os.path.isfile(script):
print('[-] invalid script file: ' + script)
return
script_path, script_name = os.path.split(script)
sys.path.append(script_path)
script_import_name = script_name
if script_import_name.endswith('.py'):
script_import_name = script_name[:-3]
sys.dont_write_bytecode = True
sys.modules['argparse'] = _FakeArgparse()
print('[*] importing: ' + script_import_name)
try:
module = imp.load_source(script_import_name, script)
except SyntaxError:
print('[!] failed to import the python file')
return
if not hasattr(module, arguments.target_function):
print("[-] the specified script has no {0}() function".format(arguments.target_function))
print('[-] can not automatically get the parser instance')
return
sys.argv = [script_name, '--help']
try:
with _muted_std_streams():
getattr(module, arguments.target_function)()
except SystemExit:
pass
if not sys.modules['argparse'].parser:
print("[-] no parser was created in {0}.{1}()".format(script_name, arguments.target_function))
return
make_completion_from_argparse(sys.modules['argparse'].parser, destination=arguments.output, input_prompt=arguments.input_prompt)
if __name__ == '__main__':
main()
@Freed-Wu
Copy link

I have a similar scheme. However, the hacker which replace sys.module["argparse"] cannot work when user inherite argparse.ArgumentParser like class ExtendedArgumentParser(argparse.ArgumentParser):.

from contextlib import suppress, redirect_stderr, redirect_stdout
import os
import sys
from types import ModuleType
from typing import Final, Literal


class Fakeparse(ModuleType):
    """Replace a sys.module with a fake module in order to get a parser.

    Examples
    --------
    >>> from contextlib import suppress
    >>> import gzip
    >>> with Fakeparse() as fakeargparse, suppress(SystemExit):
    ...    getattr(gzip, "main")()
    >>> fakeargparse.parser  # doctest: +ELLIPSIS
    ArgumentParser(...)
    """

    def __init__(self, name: Literal["argparse", "optparse"] = "argparse"):
        """__init__.

        :param name:
        :type name: Literal["argparse", "optparse"]
        """
        super().__init__(name)
        if name == "argparse":
            from argparse import ArgumentParser as TrueParser
        elif name == "optparse":
            from optparse import OptionParser as TrueParser
        else:
            raise NotImplementedError
        self.TrueParser = TrueParser
        self.trueparse = __import__(self.TrueParser.__module__)
        self.parser = None

    def __getattr__(self, attr):
        """__getattr__.

        :param attr:
        """
        if attr == self.TrueParser.__name__:
            return self.hook
        return getattr(self.trueparse, attr)

    def hook(self, *args, **kwargs):
        """hook.

        :param args:
        :param kwargs:
        """
        self.parser = self.TrueParser(*args, **kwargs)
        self.TrueParser.parse_args.__defaults__ = (["--help"], None)
        self.TrueParser.print_help = self.exit
        self.TrueParser.print_usage = self.exit
        return self.parser

    def exit(self, *args, **kwargs):
        """exit.

        :param args:
        :param kwargs:
        """
        exit()

    def __enter__(self):
        """__enter__."""
        sys.modules[self.trueparse.__name__] = self
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        """__exit__.

        :param exc_type:
        :param exc_value:
        :param traceback:
        """
        sys.modules[self.trueparse.__name__] = self.trueparse


PACKAGE: Final = sys.argv[1]
MAIN: Final = "main"
# need python 3.10
with (
    Fakeparse("argparse") as fakeargparse,
    Fakeparse("optparse") as fakeoptparse,
    open(os.devnull, "w") as f,
    redirect_stdout(f),
    redirect_stderr(f),
    suppress(SystemExit),
):
    MODULE: Final = __import__(PACKAGE)  # must import here
    getattr(MODULE, MAIN)()

if fakeargparse.parser:
    parser = fakeargparse.parser
    from argparse import (
        SUPPRESS,
        _HelpAction,
        _VersionAction,
        _SubParsersAction,
        FileType,
    )

    SUPPRESS_HELP = SUPPRESS
    SUPPRESS_USAGE = SUPPRESS
    actions = parser._actions  # type: ignore
else:  # fakeoptparse.parser
    parser = fakeoptparse.parser
    from optparse import SUPPRESS_HELP, SUPPRESS_USAGE

    actions = [
        opt for group in parser.option_groups for opt in group.option_list  # type: ignore
    ]
# TODO: use actions to generate completion script

@parkouss
Copy link

@zeroSteiner thanks for this!

@Freed-Wu
Copy link

Now I use https://docs.iterative.ai/shtab/ which is a wrapped library.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment