Use spack commands --format=bash
to generate shell completion (#14393)
This PR adds a `--format=bash` option to `spack commands` to auto-generate the Bash programmable tab completion script. It can be extended to work for other shells. Progress: - [x] Fix bug in superclass initialization in `ArgparseWriter` - [x] Refactor `ArgparseWriter` (see below) - [x] Ensure that output of old `--format` options remains the same - [x] Add `ArgparseCompletionWriter` and `BashCompletionWriter` - [x] Add `--aliases` option to add command aliases - [x] Standardize positional argument names - [x] Tests for `spack commands --format=bash` coverage - [x] Tests to make sure `spack-completion.bash` stays up-to-date - [x] Tests for `spack-completion.bash` coverage - [x] Speed up `spack-completion.bash` by caching subroutine calls This PR also necessitates a significant refactoring of `ArgparseWriter`. Previously, `ArgparseWriter` was mostly a single `_write` method which handled everything from extracting the information we care about from the parser to formatting the output. Now, `_write` only handles recursion, while the information extraction is split into a separate `parse` method, and the formatting is handled by `format`. This allows subclasses to completely redefine how the format will appear without overriding all of `_write`. Co-Authored-by: Todd Gamblin <tgamblin@llnl.gov>
This commit is contained in:

committed by
Todd Gamblin

parent
8011fedd9c
commit
11f2b61261
@@ -4,134 +4,162 @@
|
||||
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import re
|
||||
import argparse
|
||||
import errno
|
||||
import sys
|
||||
|
||||
from six import StringIO
|
||||
|
||||
|
||||
class Command(object):
|
||||
"""Parsed representation of a command from argparse.
|
||||
|
||||
This is a single command from an argparse parser. ``ArgparseWriter``
|
||||
creates these and returns them from ``parse()``, and it passes one of
|
||||
these to each call to ``format()`` so that we can take an action for
|
||||
a single command.
|
||||
|
||||
Parts of a Command:
|
||||
- prog: command name (str)
|
||||
- description: command description (str)
|
||||
- usage: command usage (str)
|
||||
- positionals: list of positional arguments (list)
|
||||
- optionals: list of optional arguments (list)
|
||||
- subcommands: list of subcommand parsers (list)
|
||||
"""
|
||||
def __init__(self, prog, description, usage,
|
||||
positionals, optionals, subcommands):
|
||||
self.prog = prog
|
||||
self.description = description
|
||||
self.usage = usage
|
||||
self.positionals = positionals
|
||||
self.optionals = optionals
|
||||
self.subcommands = subcommands
|
||||
|
||||
|
||||
# NOTE: The only reason we subclass argparse.HelpFormatter is to get access
|
||||
# to self._expand_help(), ArgparseWriter is not intended to be used as a
|
||||
# formatter_class.
|
||||
class ArgparseWriter(argparse.HelpFormatter):
|
||||
"""Analyzes an argparse ArgumentParser for easy generation of help."""
|
||||
def __init__(self, out=sys.stdout):
|
||||
super(ArgparseWriter, self).__init__(out)
|
||||
|
||||
def __init__(self, prog, out=sys.stdout, aliases=False):
|
||||
"""Initializes a new ArgparseWriter instance.
|
||||
|
||||
Parameters:
|
||||
prog (str): the program name
|
||||
out (file object): the file to write to
|
||||
aliases (bool): whether or not to include subparsers for aliases
|
||||
"""
|
||||
super(ArgparseWriter, self).__init__(prog)
|
||||
self.level = 0
|
||||
self.prog = prog
|
||||
self.out = out
|
||||
self.aliases = aliases
|
||||
|
||||
def _write(self, parser, root=True, level=0):
|
||||
def parse(self, parser, prog):
|
||||
"""Parses the parser object and returns the relavent components.
|
||||
|
||||
Parameters:
|
||||
parser (argparse.ArgumentParser): the parser
|
||||
prog (str): the command name
|
||||
|
||||
Returns:
|
||||
(Command) information about the command from the parser
|
||||
"""
|
||||
self.parser = parser
|
||||
self.level = level
|
||||
|
||||
split_prog = parser.prog.split(' ')
|
||||
split_prog[-1] = prog
|
||||
prog = ' '.join(split_prog)
|
||||
description = parser.description
|
||||
|
||||
fmt = parser._get_formatter()
|
||||
actions = parser._actions
|
||||
groups = parser._mutually_exclusive_groups
|
||||
usage = fmt._format_usage(None, actions, groups, '').strip()
|
||||
|
||||
# allow root level to be flattened with rest of commands
|
||||
if type(root) == int:
|
||||
self.level = root
|
||||
root = True
|
||||
|
||||
# go through actions and split them into optionals, positionals,
|
||||
# Go through actions and split them into optionals, positionals,
|
||||
# and subcommands
|
||||
optionals = []
|
||||
positionals = []
|
||||
subcommands = []
|
||||
for action in actions:
|
||||
if action.option_strings:
|
||||
optionals.append(action)
|
||||
flags = action.option_strings
|
||||
dest_flags = fmt._format_action_invocation(action)
|
||||
help = self._expand_help(action) if action.help else ''
|
||||
help = help.replace('\n', ' ')
|
||||
optionals.append((flags, dest_flags, help))
|
||||
elif isinstance(action, argparse._SubParsersAction):
|
||||
for subaction in action._choices_actions:
|
||||
subparser = action._name_parser_map[subaction.dest]
|
||||
subcommands.append(subparser)
|
||||
subcommands.append((subparser, subaction.dest))
|
||||
|
||||
# Look for aliases of the form 'name (alias, ...)'
|
||||
if self.aliases:
|
||||
match = re.match(r'(.*) \((.*)\)', subaction.metavar)
|
||||
if match:
|
||||
aliases = match.group(2).split(', ')
|
||||
for alias in aliases:
|
||||
subparser = action._name_parser_map[alias]
|
||||
subcommands.append((subparser, alias))
|
||||
else:
|
||||
positionals.append(action)
|
||||
|
||||
groups = parser._mutually_exclusive_groups
|
||||
fmt = parser._get_formatter()
|
||||
description = parser.description
|
||||
|
||||
def action_group(function, actions):
|
||||
for action in actions:
|
||||
arg = fmt._format_action_invocation(action)
|
||||
args = fmt._format_action_invocation(action)
|
||||
help = self._expand_help(action) if action.help else ''
|
||||
function(arg, re.sub('\n', ' ', help))
|
||||
help = help.replace('\n', ' ')
|
||||
positionals.append((args, help))
|
||||
|
||||
if root:
|
||||
self.begin_command(parser.prog)
|
||||
return Command(
|
||||
prog, description, usage, positionals, optionals, subcommands)
|
||||
|
||||
if description:
|
||||
self.description(parser.description)
|
||||
def format(self, cmd):
|
||||
"""Returns the string representation of a single node in the
|
||||
parser tree.
|
||||
|
||||
usage = fmt._format_usage(None, actions, groups, '').strip()
|
||||
self.usage(usage)
|
||||
Override this in subclasses to define how each subcommand
|
||||
should be displayed.
|
||||
|
||||
if positionals:
|
||||
self.begin_positionals()
|
||||
action_group(self.positional, positionals)
|
||||
self.end_positionals()
|
||||
Parameters:
|
||||
(Command): parsed information about a command or subcommand
|
||||
|
||||
if optionals:
|
||||
self.begin_optionals()
|
||||
action_group(self.optional, optionals)
|
||||
self.end_optionals()
|
||||
Returns:
|
||||
str: the string representation of this subcommand
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
if subcommands:
|
||||
self.begin_subcommands(subcommands)
|
||||
for subparser in subcommands:
|
||||
self._write(subparser, root=True, level=level + 1)
|
||||
self.end_subcommands(subcommands)
|
||||
def _write(self, parser, prog, level=0):
|
||||
"""Recursively writes a parser.
|
||||
|
||||
if root:
|
||||
self.end_command(parser.prog)
|
||||
Parameters:
|
||||
parser (argparse.ArgumentParser): the parser
|
||||
prog (str): the command name
|
||||
level (int): the current level
|
||||
"""
|
||||
self.level = level
|
||||
|
||||
def write(self, parser, root=True):
|
||||
cmd = self.parse(parser, prog)
|
||||
self.out.write(self.format(cmd))
|
||||
|
||||
for subparser, prog in cmd.subcommands:
|
||||
self._write(subparser, prog, level=level + 1)
|
||||
|
||||
def write(self, parser):
|
||||
"""Write out details about an ArgumentParser.
|
||||
|
||||
Args:
|
||||
parser (ArgumentParser): an ``argparse`` parser
|
||||
root (bool or int): if bool, whether to include the root parser;
|
||||
or ``1`` to flatten the root parser with first-level
|
||||
subcommands
|
||||
parser (argparse.ArgumentParser): the parser
|
||||
"""
|
||||
try:
|
||||
self._write(parser, root, level=0)
|
||||
self._write(parser, self.prog)
|
||||
except IOError as e:
|
||||
# swallow pipe errors
|
||||
# Swallow pipe errors
|
||||
# Raises IOError in Python 2 and BrokenPipeError in Python 3
|
||||
if e.errno != errno.EPIPE:
|
||||
raise
|
||||
|
||||
def begin_command(self, prog):
|
||||
pass
|
||||
|
||||
def end_command(self, prog):
|
||||
pass
|
||||
|
||||
def description(self, description):
|
||||
pass
|
||||
|
||||
def usage(self, usage):
|
||||
pass
|
||||
|
||||
def begin_positionals(self):
|
||||
pass
|
||||
|
||||
def positional(self, name, help):
|
||||
pass
|
||||
|
||||
def end_positionals(self):
|
||||
pass
|
||||
|
||||
def begin_optionals(self):
|
||||
pass
|
||||
|
||||
def optional(self, option, help):
|
||||
pass
|
||||
|
||||
def end_optionals(self):
|
||||
pass
|
||||
|
||||
def begin_subcommands(self, subcommands):
|
||||
pass
|
||||
|
||||
def end_subcommands(self, subcommands):
|
||||
pass
|
||||
|
||||
|
||||
_rst_levels = ['=', '-', '^', '~', ':', '`']
|
||||
|
||||
@@ -139,66 +167,213 @@ def end_subcommands(self, subcommands):
|
||||
class ArgparseRstWriter(ArgparseWriter):
|
||||
"""Write argparse output as rst sections."""
|
||||
|
||||
def __init__(self, out=sys.stdout, rst_levels=_rst_levels,
|
||||
strip_root_prog=True):
|
||||
def __init__(self, prog, out=sys.stdout, aliases=False,
|
||||
rst_levels=_rst_levels):
|
||||
"""Create a new ArgparseRstWriter.
|
||||
|
||||
Args:
|
||||
Parameters:
|
||||
prog (str): program name
|
||||
out (file object): file to write to
|
||||
aliases (bool): whether or not to include subparsers for aliases
|
||||
rst_levels (list of str): list of characters
|
||||
for rst section headings
|
||||
strip_root_prog (bool): if ``True``, strip the base command name
|
||||
from subcommands in output
|
||||
"""
|
||||
super(ArgparseRstWriter, self).__init__(out)
|
||||
super(ArgparseRstWriter, self).__init__(prog, out, aliases)
|
||||
self.rst_levels = rst_levels
|
||||
self.strip_root_prog = strip_root_prog
|
||||
|
||||
def line(self, string=''):
|
||||
self.out.write('%s\n' % string)
|
||||
def format(self, cmd):
|
||||
string = StringIO()
|
||||
string.write(self.begin_command(cmd.prog))
|
||||
|
||||
if cmd.description:
|
||||
string.write(self.description(cmd.description))
|
||||
|
||||
string.write(self.usage(cmd.usage))
|
||||
|
||||
if cmd.positionals:
|
||||
string.write(self.begin_positionals())
|
||||
for args, help in cmd.positionals:
|
||||
string.write(self.positional(args, help))
|
||||
string.write(self.end_positionals())
|
||||
|
||||
if cmd.optionals:
|
||||
string.write(self.begin_optionals())
|
||||
for flags, dest_flags, help in cmd.optionals:
|
||||
string.write(self.optional(dest_flags, help))
|
||||
string.write(self.end_optionals())
|
||||
|
||||
if cmd.subcommands:
|
||||
string.write(self.begin_subcommands(cmd.subcommands))
|
||||
|
||||
return string.getvalue()
|
||||
|
||||
def begin_command(self, prog):
|
||||
self.line()
|
||||
self.line('----')
|
||||
self.line()
|
||||
self.line('.. _%s:\n' % prog.replace(' ', '-'))
|
||||
self.line('%s' % prog)
|
||||
self.line(self.rst_levels[self.level] * len(prog) + '\n')
|
||||
return """
|
||||
----
|
||||
|
||||
.. _{0}:
|
||||
|
||||
{1}
|
||||
{2}
|
||||
|
||||
""".format(prog.replace(' ', '-'), prog,
|
||||
self.rst_levels[self.level] * len(prog))
|
||||
|
||||
def description(self, description):
|
||||
self.line('%s\n' % description)
|
||||
return description + '\n\n'
|
||||
|
||||
def usage(self, usage):
|
||||
self.line('.. code-block:: console\n')
|
||||
self.line(' %s\n' % usage)
|
||||
return """\
|
||||
.. code-block:: console
|
||||
|
||||
{0}
|
||||
|
||||
""".format(usage)
|
||||
|
||||
def begin_positionals(self):
|
||||
self.line()
|
||||
self.line('**Positional arguments**\n')
|
||||
return '\n**Positional arguments**\n\n'
|
||||
|
||||
def positional(self, name, help):
|
||||
self.line(name)
|
||||
self.line(' %s\n' % help)
|
||||
return """\
|
||||
{0}
|
||||
{1}
|
||||
|
||||
""".format(name, help)
|
||||
|
||||
def end_positionals(self):
|
||||
return ''
|
||||
|
||||
def begin_optionals(self):
|
||||
self.line()
|
||||
self.line('**Optional arguments**\n')
|
||||
return '\n**Optional arguments**\n\n'
|
||||
|
||||
def optional(self, opts, help):
|
||||
self.line('``%s``' % opts)
|
||||
self.line(' %s\n' % help)
|
||||
return """\
|
||||
``{0}``
|
||||
{1}
|
||||
|
||||
""".format(opts, help)
|
||||
|
||||
def end_optionals(self):
|
||||
return ''
|
||||
|
||||
def begin_subcommands(self, subcommands):
|
||||
self.line()
|
||||
self.line('**Subcommands**\n')
|
||||
self.line('.. hlist::')
|
||||
self.line(' :columns: 4\n')
|
||||
string = """
|
||||
**Subcommands**
|
||||
|
||||
for cmd in subcommands:
|
||||
prog = cmd.prog
|
||||
if self.strip_root_prog:
|
||||
prog = re.sub(r'^[^ ]* ', '', prog)
|
||||
.. hlist::
|
||||
:columns: 4
|
||||
|
||||
self.line(' * :ref:`%s <%s>`'
|
||||
% (prog, cmd.prog.replace(' ', '-')))
|
||||
self.line()
|
||||
"""
|
||||
|
||||
for cmd, _ in subcommands:
|
||||
prog = re.sub(r'^[^ ]* ', '', cmd.prog)
|
||||
string += ' * :ref:`{0} <{1}>`\n'.format(
|
||||
prog, cmd.prog.replace(' ', '-'))
|
||||
|
||||
return string + '\n'
|
||||
|
||||
|
||||
class ArgparseCompletionWriter(ArgparseWriter):
|
||||
"""Write argparse output as shell programmable tab completion functions."""
|
||||
|
||||
def format(self, cmd):
|
||||
"""Returns the string representation of a single node in the
|
||||
parser tree.
|
||||
|
||||
Override this in subclasses to define how each subcommand
|
||||
should be displayed.
|
||||
|
||||
Parameters:
|
||||
(Command): parsed information about a command or subcommand
|
||||
|
||||
Returns:
|
||||
str: the string representation of this subcommand
|
||||
"""
|
||||
|
||||
assert cmd.optionals # we should always at least have -h, --help
|
||||
assert not (cmd.positionals and cmd.subcommands) # one or the other
|
||||
|
||||
# We only care about the arguments/flags, not the help messages
|
||||
positionals = []
|
||||
if cmd.positionals:
|
||||
positionals, _ = zip(*cmd.positionals)
|
||||
optionals, _, _ = zip(*cmd.optionals)
|
||||
subcommands = []
|
||||
if cmd.subcommands:
|
||||
_, subcommands = zip(*cmd.subcommands)
|
||||
|
||||
# Flatten lists of lists
|
||||
optionals = [x for xx in optionals for x in xx]
|
||||
|
||||
return (self.start_function(cmd.prog) +
|
||||
self.body(positionals, optionals, subcommands) +
|
||||
self.end_function(cmd.prog))
|
||||
|
||||
def start_function(self, prog):
|
||||
"""Returns the syntax needed to begin a function definition.
|
||||
|
||||
Parameters:
|
||||
prog (str): the command name
|
||||
|
||||
Returns:
|
||||
str: the function definition beginning
|
||||
"""
|
||||
name = prog.replace('-', '_').replace(' ', '_')
|
||||
return '\n_{0}() {{'.format(name)
|
||||
|
||||
def end_function(self, prog=None):
|
||||
"""Returns the syntax needed to end a function definition.
|
||||
|
||||
Parameters:
|
||||
prog (str, optional): the command name
|
||||
|
||||
Returns:
|
||||
str: the function definition ending
|
||||
"""
|
||||
return '}\n'
|
||||
|
||||
def body(self, positionals, optionals, subcommands):
|
||||
"""Returns the body of the function.
|
||||
|
||||
Parameters:
|
||||
positionals (list): list of positional arguments
|
||||
optionals (list): list of optional arguments
|
||||
subcommands (list): list of subcommand parsers
|
||||
|
||||
Returns:
|
||||
str: the function body
|
||||
"""
|
||||
return ''
|
||||
|
||||
def positionals(self, positionals):
|
||||
"""Returns the syntax for reporting positional arguments.
|
||||
|
||||
Parameters:
|
||||
positionals (list): list of positional arguments
|
||||
|
||||
Returns:
|
||||
str: the syntax for positional arguments
|
||||
"""
|
||||
return ''
|
||||
|
||||
def optionals(self, optionals):
|
||||
"""Returns the syntax for reporting optional flags.
|
||||
|
||||
Parameters:
|
||||
optionals (list): list of optional arguments
|
||||
|
||||
Returns:
|
||||
str: the syntax for optional flags
|
||||
"""
|
||||
return ''
|
||||
|
||||
def subcommands(self, subcommands):
|
||||
"""Returns the syntax for reporting subcommands.
|
||||
|
||||
Parameters:
|
||||
subcommands (list): list of subcommand parsers
|
||||
|
||||
Returns:
|
||||
str: the syntax for subcommand parsers
|
||||
"""
|
||||
return ''
|
||||
|
Reference in New Issue
Block a user