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:
Adam J. Stewart
2020-01-05 23:35:23 -08:00
committed by Todd Gamblin
parent 8011fedd9c
commit 11f2b61261
52 changed files with 2860 additions and 1785 deletions

View File

@@ -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 ''