Add option for generate shell completion script
Change-Id: I66d1b3a0f6b97d6894e0d921d15962f63fb4b5f7
This commit is contained in:
parent
d313d6d65a
commit
7f4e23bcef
@ -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:
|
||||||
|
51
oslo_config/sources/templates/bash-completion.template
Normal file
51
oslo_config/sources/templates/bash-completion.template
Normal 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}
|
27
oslo_config/sources/templates/zsh-completion.template
Normal file
27
oslo_config/sources/templates/zsh-completion.template
Normal 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}
|
||||||
|
}}
|
@ -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)
|
||||||
|
|
||||||
|
@ -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': []}
|
||||||
|
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- |
|
||||||
|
Add ``--shell_completion`` argument to generate shell completion file
|
||||||
|
content. Currently bash and zsh are supported
|
Loading…
Reference in New Issue
Block a user