Add option for generate shell completion script

Change-Id: I66d1b3a0f6b97d6894e0d921d15962f63fb4b5f7
This commit is contained in:
Philippe SERAPHIN 2024-01-24 10:57:01 +01:00
parent d313d6d65a
commit 7f4e23bcef
6 changed files with 301 additions and 9 deletions

View File

@ -1955,10 +1955,13 @@ class ConfigOpts(abc.Mapping):
It has built-in support for :oslo.config:option:`config_file` and It has built-in support for :oslo.config:option:`config_file` and
:oslo.config:option:`config_dir` options. :oslo.config:option:`config_dir` options.
.. versionchanged:: 9.5.0
Added shell-completion option for generate a shell completion script.
""" """
disallow_names = ('project', 'prog', 'version', disallow_names = ('project', 'prog', 'version',
'usage', 'default_config_files', 'default_config_dirs') 'usage', 'default_config_files', 'default_config_dirs')
supported_shell_completion = ['bash', 'zsh']
# NOTE(dhellmann): This instance is reused by list_opts(). # NOTE(dhellmann): This instance is reused by list_opts().
_config_source_opt = ListOpt( _config_source_opt = ListOpt(
'config_source', 'config_source',
@ -1968,6 +1971,12 @@ class ConfigOpts(abc.Mapping):
'details for accessing configuration settings ' 'details for accessing configuration settings '
'from locations other than local files.'), 'from locations other than local files.'),
) )
# Add option for generate a shell completion script
_shell_completion_opt = StrOpt(
'shell_completion',
choices=supported_shell_completion,
help='Display a shell completion script'
)
def __init__(self): def __init__(self):
"""Construct a ConfigOpts object.""" """Construct a ConfigOpts object."""
@ -1993,6 +2002,7 @@ class ConfigOpts(abc.Mapping):
self._env_driver = _environment.EnvironmentConfigurationSource() self._env_driver = _environment.EnvironmentConfigurationSource()
self.register_opt(self._config_source_opt) self.register_opt(self._config_source_opt)
self.register_cli_opt(self._shell_completion_opt)
def _pre_setup(self, project, prog, version, usage, description, epilog, def _pre_setup(self, project, prog, version, usage, description, epilog,
default_config_files, default_config_dirs): default_config_files, default_config_dirs):
@ -2138,6 +2148,8 @@ class ConfigOpts(abc.Mapping):
:raises: SystemExit, ConfigFilesNotFoundError, ConfigFileParseError, :raises: SystemExit, ConfigFilesNotFoundError, ConfigFileParseError,
ConfigFilesPermissionDeniedError, ConfigFilesPermissionDeniedError,
RequiredOptError, DuplicateOptError RequiredOptError, DuplicateOptError
.. versionchanged:: 9.5.0
Added shell-completion option for generate a shell completion script.
""" """
self.clear() self.clear()
@ -2150,8 +2162,18 @@ class ConfigOpts(abc.Mapping):
self._setup(project, prog, version, usage, default_config_files, self._setup(project, prog, version, usage, default_config_files,
default_config_dirs, use_env) default_config_dirs, use_env)
self._namespace = self._parse_cli_opts(args if args is not None # This is necessary to analyse first args now,
else sys.argv[1:]) # because if there are subcommands,
# these are mandatory even if you only want to use
# the --shell_completion option
argv = args if args is not None else sys.argv[1:]
if len(argv) > 1 and argv[0] == '--shell_completion' \
and args[1] in self.supported_shell_completion:
shell = argv[1]
self._print_shell_completion(shell)
sys.exit(0)
self._namespace = self._parse_cli_opts(argv)
if self._namespace._files_not_found: if self._namespace._files_not_found:
raise ConfigFilesNotFoundError(self._namespace._files_not_found) raise ConfigFilesNotFoundError(self._namespace._files_not_found)
if self._namespace._files_permission_denied: if self._namespace._files_permission_denied:
@ -2162,6 +2184,190 @@ class ConfigOpts(abc.Mapping):
self._check_required_opts() self._check_required_opts()
def _print_shell_completion(self, shell):
"""Print shell completion Script
:param shell: name of shell to generate script, actually bash or zsh
"""
maps, descr, opts, opts_sub, args = {}, {}, {}, {}, {}
multi = []
template = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"templates",
f"{shell}-completion.template")
for opt, group in self._all_cli_opts():
if isinstance(opt, SubCommandOpt):
# If a subcommand, call _add_to_cli, for getting the subparser
opt._add_to_cli(self._oparser, group)
else:
name = f"{group}_{opt.dest}" \
if not (group is None and group != '') \
else f"{opt.dest}"
if opt.multi:
multi.append(name)
opts.setdefault(name, [])
opts[name] += [f"--{name}"]
descr[name] = opt.help
maps[f"--{name}"] = name
if opt.short:
opts[name] += [f"-{opt.short}"]
maps[f"-{opt.short}"] = name
if hasattr(opt.type, 'choices') and opt.type.choices:
args[name] = ' '.join(opt.type.choices.keys())
else:
args[name] = ' '
for op in self._oparser._actions:
# Analyze parser for find subcommanf options and help option
if isinstance(op, argparse._SubParsersAction):
for k, v in op._name_parser_map.items():
descr[k] = v.description if v.description else ''
opts_sub.setdefault(k, {})
for opt in v._actions:
op_str = opt.option_strings
descr[f"{k}_{opt.dest}"] = opt.help
opts_sub[k][opt.dest] = op_str
for op in op_str:
maps[op] = opt.dest
elif isinstance(op, argparse._HelpAction):
opts.setdefault(op.dest, [])
opts[op.dest] += [f"--{op.dest}", '-h']
descr[op.dest] = op.help
if shell == 'bash':
output = self._generate_bash_completion(template,
maps=maps,
opts=opts,
descr=descr,
opts_sub=opts_sub,
multi=multi,
args=args)
elif shell == 'zsh':
output = self._generate_zsh_completion(template,
maps=maps,
opts=opts,
descr=descr,
opts_sub=opts_sub,
multi=multi,
args=args)
print(output)
def _generate_bash_completion(self,
template,
maps=None,
opts=None,
descr=None,
opts_sub=None,
multi=None,
args=None):
"""Generate a bash completaion script
:param template: tamplate for generate script
:param maps: a dict mapping short and long option name with destination
variable
:param opts: a dict of option (destination is key, short and long name
is in a list in value)
:param descr: dict of help message for option
:param opts_sub: dict of subcommand
:param multi: list of MultiOPt
:param args: dict of options with arguments
"""
if maps is None:
maps = {}
if opts is None:
opts = {}
if descr is None:
descr = {}
if opts_sub is None:
opts_sub = {}
if multi is None:
multi = []
if args is None:
args = {}
b_opts_sub = ''
b_opts = ' '.join([f"[{k}]='{' '.join(v)}'"
for k, v in opts.items()])
b_args = ' '.join([f"[{k}]='{v}'" for k, v in args.items()])
for k, v in opts_sub.items():
b_opts += f" [{k}]='{k}'"
sub = '|'.join([f"{ik}=\"{' '.join(iv)}\""
for ik, iv in v.items() if len(iv) > 0])
b_opts_sub += f"[{k}]='{sub}' "
b_map = ' '.join([f"[{k}]={v}" for k, v in maps.items()])
b_multi = ' '.join([f"[{k}]=true" for k in multi])
with open(template, "r") as input:
output = input.read().format(scriptname=self.prog,
opts=b_opts,
opts_sub=b_opts_sub,
args=b_args,
multi=b_multi,
map=b_map)
return output
def _generate_zsh_completion(self,
template,
maps=None,
opts=None,
descr=None,
opts_sub=None,
multi=None,
args=None):
"""Generate a zsh completaion script
:param template: tamplate for generate script
:param maps: a dict mapping short and long option name with destination
variable
:param opts: a dict of option (destination is key, short and long name
is in a list in value)
:param descr: dict of help message for option
:param opts_sub: dict of subcommand
:param multi: list of MultiOPt
:param args: dict of options with arguments
"""
if maps is None:
maps = {}
if opts is None:
opts = {}
if descr is None:
descr = {}
if opts_sub is None:
opts_sub = {}
if multi is None:
multi = []
if args is None:
args = {}
p = self.prog
t = ' '
z_opts = ''
for k, v in opts.items():
repeat = '*' if k in multi else f"({' '.join(v)})"
o = f"{{{','.join(v)}}}" if len(v) > 1 else v[0]
d = descr[k]
c = f":choice:({args[k]})" if k in args else ''
z_opts += f"{t*2}'{repeat}'{o}'[{d}]{c}' \\\n"
if opts_sub:
z_opts += f"{t*2}'*::{p} command:_{p}_commands'\n"
c_list = ''
c_opts = ''
for k, v in opts_sub.items():
desc = descr[k] if descr[k] else ''
c_list += f"{t*2}'{k}:{desc}'\n"
c_opts += f"{t*3}{k})\n"
c_opts += f"{t*4}_arguments -s \\\n"
for ik, iv in v.items():
if len(iv) > 1:
c_opts += f"{t*4}'({' '.join(iv)})'{{{','.join(iv)}}}"
elif len(iv) == 1:
c_opts += f"{t*4}{iv[0]}"
desc = descr[k+'_'+ik] if descr[k+'_'+ik] else ''
c_opts += f"'[{desc}]' \\\n"
c_opts += f"\n{t*4};;\n"
with open(template, "r") as input:
output = input.read().format(scriptname=p,
opts=z_opts,
commands_list=c_list,
commands_opts=c_opts)
return output
def _load_alternative_sources(self): def _load_alternative_sources(self):
# Look for other sources of option data. # Look for other sources of option data.
for source_group_name in self.config_source: for source_group_name in self.config_source:

View File

@ -0,0 +1,51 @@
#!/bin/bash completion for {scriptname}
_{scriptname}(){{
local cur prev
local -A ARGS MAP FORCE OPTS OPTS_SUB MULTI
COMPREPLY=()
cur="${{COMP_WORDS[COMP_CWORD]}}"
prev="${{COMP_WORDS[COMP_CWORD-1]}}"
OPTS=({opts})
OPTS_SUB=({opts_sub})
ARGS=({args})
MAP=({map})
MULTI=({multi})
if [ ! -z "$prev" ]; then
# if is an argument complete with list of choice if define
prev_key=${{MAP[$prev]}}
if [ ! -z $prev_key ] && [ ! -z "${{ARGS[$prev_key]}}" ]; then
COMPREPLY=($(compgen -W "${{ARGS[$prev_key]}}" -- "${{cur}}"))
return 0
fi
fi
for in_use in ${{COMP_WORDS[@]:1}}; do
key=${{MAP[$in_use]}}
IFS='|'
if [[ -v OPTS_SUB[$key] ]];then
# If is a subcommand redefine completion
unset OPTS
local -A OPTS
for el in ${{OPTS_SUB[$key]}}; do
IFS='='
read k v <<< ${{el}}
IFS='|'
OPTS+=( [${{k}}]="${{v}}" )
done
fi
unset IFS
# Unset option that is already use
if [[ -z "MULTI[$key]" ]]; then
unset OPTS[$key]
unset ARGS[$key]
fi
done
compl="${{OPTS[@]}}"
COMPREPLY=($(compgen -W "${{compl}}" -- "${{cur}}"))
return 0
}}
complete -F _{scriptname} {scriptname}

View File

@ -0,0 +1,27 @@
#compdef _{scriptname} {scriptname}
_{scriptname}_commands(){{
#Script used only if subcommand
local -a _{scriptname}_cmds
# Add subcommands list
_{scriptname}_cmds=(
{commands_list}
)
if (( CURRENT == 1 )); then
_describe -t commands '{scriptname} command' _{scriptname}_cmds || compadd "$@"
else
local curcontext="$curcontext"
#Check if subcommand and redefine completion
case "$words[1]" in
{commands_opts}
esac
fi
}}
_{scriptname}(){{
local curcontext="$curcontext" state line
_arguments -s \
{opts}
}}

View File

@ -143,8 +143,8 @@ class UsageTestCase(BaseTestCase):
self.conf([]) self.conf([])
self.conf.print_usage(file=f) self.conf.print_usage(file=f)
self.assertIn( self.assertIn(
'usage: test [-h] [--config-dir DIR] [--config-file PATH] ' 'usage: test [-h] [--config-dir DIR] [--config-file PATH]\n\
'[--version]', [--shell_completion SHELL_COMPLETION] [--version]',
f.getvalue()) f.getvalue())
self.assertNotIn('somedesc', f.getvalue()) self.assertNotIn('somedesc', f.getvalue())
self.assertNotIn('tepilog', f.getvalue()) self.assertNotIn('tepilog', f.getvalue())
@ -167,8 +167,8 @@ class UsageTestCase(BaseTestCase):
self.conf([]) self.conf([])
self.conf.print_help(file=f) self.conf.print_help(file=f)
self.assertIn( self.assertIn(
'usage: test [-h] [--config-dir DIR] [--config-file PATH] ' 'usage: test [-h] [--config-dir DIR] [--config-file PATH]\n\
'[--version]', [--shell_completion SHELL_COMPLETION] [--version]',
f.getvalue()) f.getvalue())
self.assertIn('somedesc', f.getvalue()) self.assertIn('somedesc', f.getvalue())
self.assertIn('tepilog', f.getvalue()) self.assertIn('tepilog', f.getvalue())
@ -182,8 +182,8 @@ class HelpTestCase(BaseTestCase):
self.conf([]) self.conf([])
self.conf.print_help(file=f) self.conf.print_help(file=f)
self.assertIn( self.assertIn(
'usage: test [-h] [--config-dir DIR] [--config-file PATH] ' 'usage: test [-h] [--config-dir DIR] [--config-file PATH]\n\
'[--version]', [--shell_completion SHELL_COMPLETION] [--version]',
f.getvalue()) f.getvalue())
# argparse may generate two different help messages: # argparse may generate two different help messages:
# - In Python >=3.10: "options:\n --version" # - In Python >=3.10: "options:\n --version"
@ -2629,7 +2629,7 @@ class MappingInterfaceTestCase(BaseTestCase):
self.assertIn('foo', self.conf) self.assertIn('foo', self.conf)
self.assertIn('config_file', self.conf) self.assertIn('config_file', self.conf)
self.assertEqual(len(self.conf), 4) self.assertEqual(len(self.conf), 5)
self.assertEqual('bar', self.conf['foo']) self.assertEqual('bar', self.conf['foo'])
self.assertEqual('bar', self.conf.get('foo')) self.assertEqual('bar', self.conf.get('foo'))
self.assertIn('bar', list(self.conf.values())) self.assertIn('bar', list(self.conf.values()))
@ -4036,6 +4036,7 @@ class OptDumpingTestCase(BaseTestCase):
"config_source = []", "config_source = []",
"foo = this", "foo = this",
"passwd = ****", "passwd = ****",
"shell_completion = None",
"blaa.bar = that", "blaa.bar = that",
"blaa.key = ****", "blaa.key = ****",
"*" * 80, "*" * 80,
@ -4061,6 +4062,7 @@ class OptDumpingTestCase(BaseTestCase):
"config files: []", "config files: []",
"=" * 80, "=" * 80,
"config_source = []", "config_source = []",
"shell_completion = None",
"*" * 80, "*" * 80,
], logger.logged) ], logger.logged)

View File

@ -1132,6 +1132,7 @@ GENERATOR_OPTS = {'format_': 'yaml',
'namespace': ['test'], 'namespace': ['test'],
'output_file': None, 'output_file': None,
'summarize': False, 'summarize': False,
'shell_completion': None,
'wrap_width': 70, 'wrap_width': 70,
'config_source': []} 'config_source': []}

View File

@ -0,0 +1,5 @@
---
features:
- |
Add ``--shell_completion`` argument to generate shell completion file
content. Currently bash and zsh are supported