From 297891152077a018acb7dd2b656e9018e46a5d5c Mon Sep 17 00:00:00 2001 From: "Adam J. Stewart" Date: Tue, 4 Jul 2023 15:43:02 -0500 Subject: spack commands: add type hints and docstrings (#38705) --- lib/spack/llnl/util/argparsewriter.py | 329 ++++++++++++++--------- lib/spack/spack/cmd/commands.py | 195 +++++++++++--- lib/spack/spack/test/llnl/util/argparsewriter.py | 6 +- 3 files changed, 371 insertions(+), 159 deletions(-) diff --git a/lib/spack/llnl/util/argparsewriter.py b/lib/spack/llnl/util/argparsewriter.py index d9aa4b471b..dfd602bb34 100644 --- a/lib/spack/llnl/util/argparsewriter.py +++ b/lib/spack/llnl/util/argparsewriter.py @@ -3,31 +3,42 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) +import abc import argparse -import errno import io import re import sys +from argparse import ArgumentParser +from typing import IO, Optional, Sequence, Tuple -class Command(object): +class Command: """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) + 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. """ - def __init__(self, prog, description, usage, positionals, optionals, subcommands): + def __init__( + self, + prog: str, + description: Optional[str], + usage: str, + positionals: Sequence[Tuple[str, str]], + optionals: Sequence[Tuple[Sequence[str], str, str]], + subcommands: Sequence[Tuple[ArgumentParser, str]], + ) -> None: + """Initialize a new Command instance. + + Args: + prog: Program name. + description: Command description. + usage: Command usage. + positionals: List of positional arguments. + optionals: List of optional arguments. + subcommands: List of subcommand parsers. + """ self.prog = prog self.description = description self.usage = usage @@ -36,35 +47,34 @@ class Command(object): 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.""" +# 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, abc.ABC): + """Analyze an argparse ArgumentParser for easy generation of help.""" - def __init__(self, prog, out=None, aliases=False): - """Initializes a new ArgparseWriter instance. + def __init__(self, prog: str, out: IO = sys.stdout, aliases: bool = False) -> None: + """Initialize a new ArgparseWriter instance. - Parameters: - prog (str): the program name - out (file object): the file to write to (default sys.stdout) - aliases (bool): whether or not to include subparsers for aliases + Args: + prog: Program name. + out: File object to write to. + aliases: Whether or not to include subparsers for aliases. """ - super(ArgparseWriter, self).__init__(prog) + super().__init__(prog) self.level = 0 self.prog = prog - self.out = sys.stdout if out is None else out + self.out = out self.aliases = aliases - def parse(self, parser, prog): - """Parses the parser object and returns the relavent components. + def parse(self, parser: ArgumentParser, prog: str) -> Command: + """Parse the parser object and return the relavent components. - Parameters: - parser (argparse.ArgumentParser): the parser - prog (str): the command name + Args: + parser: Command parser. + prog: Program name. Returns: - (Command) information about the command from the parser + Information about the command from the parser. """ self.parser = parser @@ -78,8 +88,7 @@ class ArgparseWriter(argparse.HelpFormatter): groups = parser._mutually_exclusive_groups usage = fmt._format_usage(None, actions, groups, "").strip() - # Go through actions and split them into optionals, positionals, - # and subcommands + # Go through actions and split them into optionals, positionals, and subcommands optionals = [] positionals = [] subcommands = [] @@ -96,7 +105,7 @@ class ArgparseWriter(argparse.HelpFormatter): subcommands.append((subparser, subaction.dest)) # Look for aliases of the form 'name (alias, ...)' - if self.aliases: + if self.aliases and isinstance(subaction.metavar, str): match = re.match(r"(.*) \((.*)\)", subaction.metavar) if match: aliases = match.group(2).split(", ") @@ -111,28 +120,26 @@ class ArgparseWriter(argparse.HelpFormatter): return Command(prog, description, usage, positionals, optionals, subcommands) - def format(self, cmd): - """Returns the string representation of a single node in the - parser tree. + @abc.abstractmethod + def format(self, cmd: Command) -> str: + """Return the string representation of a single node in the parser tree. - Override this in subclasses to define how each subcommand - should be displayed. + Override this in subclasses to define how each subcommand should be displayed. - Parameters: - (Command): parsed information about a command or subcommand + Args: + cmd: Parsed information about a command or subcommand. Returns: - str: the string representation of this subcommand + String representation of this subcommand. """ - raise NotImplementedError - def _write(self, parser, prog, level=0): - """Recursively writes a parser. + def _write(self, parser: ArgumentParser, prog: str, level: int = 0) -> None: + """Recursively write a parser. - Parameters: - parser (argparse.ArgumentParser): the parser - prog (str): the command name - level (int): the current level + Args: + parser: Command parser. + prog: Program name. + level: Current level. """ self.level = level @@ -142,19 +149,17 @@ class ArgparseWriter(argparse.HelpFormatter): for subparser, prog in cmd.subcommands: self._write(subparser, prog, level=level + 1) - def write(self, parser): + def write(self, parser: ArgumentParser) -> None: """Write out details about an ArgumentParser. Args: - parser (argparse.ArgumentParser): the parser + parser: Command parser. """ try: self._write(parser, self.prog) - except IOError as e: + except BrokenPipeError: # Swallow pipe errors - # Raises IOError in Python 2 and BrokenPipeError in Python 3 - if e.errno != errno.EPIPE: - raise + pass _rst_levels = ["=", "-", "^", "~", ":", "`"] @@ -163,21 +168,33 @@ _rst_levels = ["=", "-", "^", "~", ":", "`"] class ArgparseRstWriter(ArgparseWriter): """Write argparse output as rst sections.""" - def __init__(self, prog, out=None, aliases=False, rst_levels=_rst_levels): - """Create a new ArgparseRstWriter. + def __init__( + self, + prog: str, + out: IO = sys.stdout, + aliases: bool = False, + rst_levels: Sequence[str] = _rst_levels, + ) -> None: + """Initialize a new ArgparseRstWriter instance. - 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 + Args: + prog: Program name. + out: File object to write to. + aliases: Whether or not to include subparsers for aliases. + rst_levels: List of characters for rst section headings. """ - out = sys.stdout if out is None else out - super(ArgparseRstWriter, self).__init__(prog, out, aliases) + super().__init__(prog, out, aliases) self.rst_levels = rst_levels - def format(self, cmd): + def format(self, cmd: Command) -> str: + """Return the string representation of a single node in the parser tree. + + Args: + cmd: Parsed information about a command or subcommand. + + Returns: + String representation of a node. + """ string = io.StringIO() string.write(self.begin_command(cmd.prog)) @@ -203,7 +220,15 @@ class ArgparseRstWriter(ArgparseWriter): return string.getvalue() - def begin_command(self, prog): + def begin_command(self, prog: str) -> str: + """Text to print before a command. + + Args: + prog: Program name. + + Returns: + Text before a command. + """ return """ ---- @@ -216,10 +241,26 @@ class ArgparseRstWriter(ArgparseWriter): prog.replace(" ", "-"), prog, self.rst_levels[self.level] * len(prog) ) - def description(self, description): + def description(self, description: str) -> str: + """Description of a command. + + Args: + description: Command description. + + Returns: + Description of a command. + """ return description + "\n\n" - def usage(self, usage): + def usage(self, usage: str) -> str: + """Example usage of a command. + + Args: + usage: Command usage. + + Returns: + Usage of a command. + """ return """\ .. code-block:: console @@ -229,10 +270,24 @@ class ArgparseRstWriter(ArgparseWriter): usage ) - def begin_positionals(self): + def begin_positionals(self) -> str: + """Text to print before positional arguments. + + Returns: + Positional arguments header. + """ return "\n**Positional arguments**\n\n" - def positional(self, name, help): + def positional(self, name: str, help: str) -> str: + """Description of a positional argument. + + Args: + name: Argument name. + help: Help text. + + Returns: + Positional argument description. + """ return """\ {0} {1} @@ -241,13 +296,32 @@ class ArgparseRstWriter(ArgparseWriter): name, help ) - def end_positionals(self): + def end_positionals(self) -> str: + """Text to print after positional arguments. + + Returns: + Positional arguments footer. + """ return "" - def begin_optionals(self): + def begin_optionals(self) -> str: + """Text to print before optional arguments. + + Returns: + Optional arguments header. + """ return "\n**Optional arguments**\n\n" - def optional(self, opts, help): + def optional(self, opts: str, help: str) -> str: + """Description of an optional argument. + + Args: + opts: Optional argument. + help: Help text. + + Returns: + Optional argument description. + """ return """\ ``{0}`` {1} @@ -256,10 +330,23 @@ class ArgparseRstWriter(ArgparseWriter): opts, help ) - def end_optionals(self): + def end_optionals(self) -> str: + """Text to print after optional arguments. + + Returns: + Optional arguments footer. + """ return "" - def begin_subcommands(self, subcommands): + def begin_subcommands(self, subcommands: Sequence[Tuple[ArgumentParser, str]]) -> str: + """Table with links to other subcommands. + + Arguments: + subcommands: List of subcommands. + + Returns: + Subcommand linking text. + """ string = """ **Subcommands** @@ -278,29 +365,25 @@ class ArgparseRstWriter(ArgparseWriter): 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. + def format(self, cmd: Command) -> str: + """Return 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 + Args: + cmd: Parsed information about a command or subcommand. Returns: - str: the string representation of this subcommand + 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 = [] + positionals: Tuple[str, ...] = () if cmd.positionals: positionals, _ = zip(*cmd.positionals) optionals, _, _ = zip(*cmd.optionals) - subcommands = [] + subcommands: Tuple[str, ...] = () if cmd.subcommands: _, subcommands = zip(*cmd.subcommands) @@ -313,71 +396,73 @@ class ArgparseCompletionWriter(ArgparseWriter): + self.end_function(cmd.prog) ) - def start_function(self, prog): - """Returns the syntax needed to begin a function definition. + def start_function(self, prog: str) -> str: + """Return the syntax needed to begin a function definition. - Parameters: - prog (str): the command name + Args: + prog: Program name. Returns: - str: the function definition beginning + 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. + def end_function(self, prog: str) -> str: + """Return the syntax needed to end a function definition. - Parameters: - prog (str or None): the command name + Args: + prog: Program name Returns: - str: the function definition ending + Function definition ending. """ return "}\n" - def body(self, positionals, optionals, subcommands): - """Returns the body of the function. + def body( + self, positionals: Sequence[str], optionals: Sequence[str], subcommands: Sequence[str] + ) -> str: + """Return the body of the function. - Parameters: - positionals (list): list of positional arguments - optionals (list): list of optional arguments - subcommands (list): list of subcommand parsers + Args: + positionals: List of positional arguments. + optionals: List of optional arguments. + subcommands: List of subcommand parsers. Returns: - str: the function body + Function body. """ return "" - def positionals(self, positionals): - """Returns the syntax for reporting positional arguments. + def positionals(self, positionals: Sequence[str]) -> str: + """Return the syntax for reporting positional arguments. - Parameters: - positionals (list): list of positional arguments + Args: + positionals: List of positional arguments. Returns: - str: the syntax for positional arguments + Syntax for positional arguments. """ return "" - def optionals(self, optionals): - """Returns the syntax for reporting optional flags. + def optionals(self, optionals: Sequence[str]) -> str: + """Return the syntax for reporting optional flags. - Parameters: - optionals (list): list of optional arguments + Args: + optionals: List of optional arguments. Returns: - str: the syntax for optional flags + Syntax for optional flags. """ return "" - def subcommands(self, subcommands): - """Returns the syntax for reporting subcommands. + def subcommands(self, subcommands: Sequence[str]) -> str: + """Return the syntax for reporting subcommands. - Parameters: - subcommands (list): list of subcommand parsers + Args: + subcommands: List of subcommand parsers. Returns: - str: the syntax for subcommand parsers + Syntax for subcommand parsers """ return "" diff --git a/lib/spack/spack/cmd/commands.py b/lib/spack/spack/cmd/commands.py index e48bb35f63..6af7bb54e8 100644 --- a/lib/spack/spack/cmd/commands.py +++ b/lib/spack/spack/cmd/commands.py @@ -8,10 +8,17 @@ import copy import os import re import sys +from argparse import ArgumentParser, Namespace +from typing import IO, Any, Callable, Dict, Sequence, Set import llnl.util.filesystem as fs import llnl.util.tty as tty -from llnl.util.argparsewriter import ArgparseCompletionWriter, ArgparseRstWriter, ArgparseWriter +from llnl.util.argparsewriter import ( + ArgparseCompletionWriter, + ArgparseRstWriter, + ArgparseWriter, + Command, +) from llnl.util.tty.colify import colify import spack.cmd @@ -25,12 +32,12 @@ level = "long" #: list of command formatters -formatters = {} +formatters: Dict[str, Callable[[Namespace, IO], None]] = {} #: standard arguments for updating completion scripts #: we iterate through these when called with --update-completion -update_completion_args = { +update_completion_args: Dict[str, Dict[str, Any]] = { "bash": { "aliases": True, "format": "bash", @@ -40,13 +47,25 @@ update_completion_args = { } -def formatter(func): - """Decorator used to register formatters""" +def formatter(func: Callable[[Namespace, IO], None]) -> Callable[[Namespace, IO], None]: + """Decorator used to register formatters. + + Args: + func: Formatting function. + + Returns: + The same function. + """ formatters[func.__name__] = func return func -def setup_parser(subparser): +def setup_parser(subparser: ArgumentParser) -> None: + """Set up the argument parser. + + Args: + subparser: Preliminary argument parser. + """ subparser.add_argument( "--update-completion", action="store_true", @@ -89,18 +108,34 @@ class SpackArgparseRstWriter(ArgparseRstWriter): def __init__( self, - prog, - out=None, - aliases=False, - documented_commands=[], - rst_levels=["-", "-", "^", "~", ":", "`"], + prog: str, + out: IO = sys.stdout, + aliases: bool = False, + documented_commands: Set[str] = set(), + rst_levels: Sequence[str] = ["-", "-", "^", "~", ":", "`"], ): - out = sys.stdout if out is None else out - super(SpackArgparseRstWriter, self).__init__(prog, out, aliases, rst_levels) + """Initialize a new SpackArgparseRstWriter instance. + + Args: + prog: Program name. + out: File object to write to. + aliases: Whether or not to include subparsers for aliases. + documented_commands: Set of commands with additional documentation. + rst_levels: List of characters for rst section headings. + """ + super().__init__(prog, out, aliases, rst_levels) self.documented = documented_commands - def usage(self, *args): - string = super(SpackArgparseRstWriter, self).usage(*args) + def usage(self, usage: str) -> str: + """Example usage of a command. + + Args: + usage: Command usage. + + Returns: + Usage of a command. + """ + string = super().usage(usage) cmd = self.parser.prog.replace(" ", "-") if cmd in self.documented: @@ -110,11 +145,21 @@ class SpackArgparseRstWriter(ArgparseRstWriter): class SubcommandWriter(ArgparseWriter): - def format(self, cmd): + """Write argparse output as a list of subcommands.""" + + def format(self, cmd: Command) -> str: + """Return the string representation of a single node in the parser tree. + + Args: + cmd: Parsed information about a command or subcommand. + + Returns: + String representation of this subcommand. + """ return " " * self.level + cmd.prog + "\n" -_positional_to_subroutine = { +_positional_to_subroutine: Dict[str, str] = { "package": "_all_packages", "spec": "_all_packages", "filter": "_all_packages", @@ -136,7 +181,19 @@ _positional_to_subroutine = { class BashCompletionWriter(ArgparseCompletionWriter): """Write argparse output as bash programmable tab completion.""" - def body(self, positionals, optionals, subcommands): + def body( + self, positionals: Sequence[str], optionals: Sequence[str], subcommands: Sequence[str] + ) -> str: + """Return the body of the function. + + Args: + positionals: List of positional arguments. + optionals: List of optional arguments. + subcommands: List of subcommand parsers. + + Returns: + Function body. + """ if positionals: return """ if $list_options @@ -166,7 +223,15 @@ class BashCompletionWriter(ArgparseCompletionWriter): self.optionals(optionals) ) - def positionals(self, positionals): + def positionals(self, positionals: Sequence[str]) -> str: + """Return the syntax for reporting positional arguments. + + Args: + positionals: List of positional arguments. + + Returns: + Syntax for positional arguments. + """ # If match found, return function name for positional in positionals: for key, value in _positional_to_subroutine.items(): @@ -176,22 +241,49 @@ class BashCompletionWriter(ArgparseCompletionWriter): # If no matches found, return empty list return 'SPACK_COMPREPLY=""' - def optionals(self, optionals): + def optionals(self, optionals: Sequence[str]) -> str: + """Return the syntax for reporting optional flags. + + Args: + optionals: List of optional arguments. + + Returns: + Syntax for optional flags. + """ return 'SPACK_COMPREPLY="{0}"'.format(" ".join(optionals)) - def subcommands(self, subcommands): + def subcommands(self, subcommands: Sequence[str]) -> str: + """Return the syntax for reporting subcommands. + + Args: + subcommands: List of subcommand parsers. + + Returns: + Syntax for subcommand parsers + """ return 'SPACK_COMPREPLY="{0}"'.format(" ".join(subcommands)) @formatter -def subcommands(args, out): +def subcommands(args: Namespace, out: IO) -> None: + """Hierarchical tree of subcommands. + + args: + args: Command-line arguments. + out: File object to write to. + """ parser = spack.main.make_argument_parser() spack.main.add_all_commands(parser) writer = SubcommandWriter(parser.prog, out, args.aliases) writer.write(parser) -def rst_index(out): +def rst_index(out: IO) -> None: + """Generate an index of all commands. + + Args: + out: File object to write to. + """ out.write("\n") index = spack.main.index_commands() @@ -219,13 +311,19 @@ def rst_index(out): @formatter -def rst(args, out): +def rst(args: Namespace, out: IO) -> None: + """ReStructuredText documentation of subcommands. + + args: + args: Command-line arguments. + out: File object to write to. + """ # create a parser with all commands parser = spack.main.make_argument_parser() spack.main.add_all_commands(parser) # extract cross-refs of the form `_cmd-spack-:` from rst files - documented_commands = set() + documented_commands: Set[str] = set() for filename in args.rst_files: with open(filename) as f: for line in f: @@ -243,7 +341,13 @@ def rst(args, out): @formatter -def names(args, out): +def names(args: Namespace, out: IO) -> None: + """Simple list of top-level commands. + + args: + args: Command-line arguments. + out: File object to write to. + """ commands = copy.copy(spack.cmd.all_commands()) if args.aliases: @@ -253,7 +357,13 @@ def names(args, out): @formatter -def bash(args, out): +def bash(args: Namespace, out: IO) -> None: + """Bash tab-completion script. + + args: + args: Command-line arguments. + out: File object to write to. + """ parser = spack.main.make_argument_parser() spack.main.add_all_commands(parser) @@ -261,7 +371,13 @@ def bash(args, out): writer.write(parser) -def prepend_header(args, out): +def prepend_header(args: Namespace, out: IO) -> None: + """Prepend header text at the beginning of a file. + + Args: + args: Command-line arguments. + out: File object to write to. + """ if not args.header: return @@ -269,10 +385,14 @@ def prepend_header(args, out): out.write(header.read()) -def _commands(parser, args): +def _commands(parser: ArgumentParser, args: Namespace) -> None: """This is the 'regular' command, which can be called multiple times. See ``commands()`` below for ``--update-completion`` handling. + + Args: + parser: Argument parser. + args: Command-line arguments. """ formatter = formatters[args.format] @@ -294,12 +414,15 @@ def _commands(parser, args): formatter(args, sys.stdout) -def update_completion(parser, args): +def update_completion(parser: ArgumentParser, args: Namespace) -> None: """Iterate through the shells and update the standard completion files. This is a convenience method to avoid calling this command many times, and to simplify completion update for developers. + Args: + parser: Argument parser. + args: Command-line arguments. """ for shell, shell_args in update_completion_args.items(): for attr, value in shell_args.items(): @@ -307,14 +430,20 @@ def update_completion(parser, args): _commands(parser, args) -def commands(parser, args): +def commands(parser: ArgumentParser, args: Namespace) -> None: + """Main function that calls formatter functions. + + Args: + parser: Argument parser. + args: Command-line arguments. + """ if args.update_completion: if args.format != "names" or any([args.aliases, args.update, args.header]): tty.die("--update-completion can only be specified alone.") # this runs the command multiple times with different arguments - return update_completion(parser, args) + update_completion(parser, args) else: # run commands normally - return _commands(parser, args) + _commands(parser, args) diff --git a/lib/spack/spack/test/llnl/util/argparsewriter.py b/lib/spack/spack/test/llnl/util/argparsewriter.py index 70b1824e2f..a2455e0303 100644 --- a/lib/spack/spack/test/llnl/util/argparsewriter.py +++ b/lib/spack/spack/test/llnl/util/argparsewriter.py @@ -20,10 +20,8 @@ spack.main.add_all_commands(parser) def test_format_not_overridden(): - writer = aw.ArgparseWriter("spack") - - with pytest.raises(NotImplementedError): - writer.write(parser) + with pytest.raises(TypeError): + aw.ArgparseWriter("spack") def test_completion_format_not_overridden(): -- cgit v1.2.3-70-g09d2