~brenns10/subc

920347f815aebf302f73e81d89eea17edff3d9ad — Stephen Brennan 11 months ago 1288115
Large feature dump

This is a major feature dump which is unfortunately not well broken
down. The features were all inspired by the Yo command line tool which
uses Subc. A brief listing of the changes is included in the changelog.

Signed-off-by: Stephen Brennan <stephen.s.brennan@oracle.com>
2 files changed, 204 insertions(+), 30 deletions(-)

M CHANGELOG.md
M subc/__init__.py
M CHANGELOG.md => CHANGELOG.md +39 -0
@@ 4,6 4,45 @@ Changelog
Unreleased
----------

Changes to client API:

- Root subclasses must now set "rootname" on their base class. It should
  be set toto the name of the root program (i.e. whatever sys.argv[0]
  would be). This is technically only needed if you use the new
  "simple_sub_parser()" method though.
- The optional "help" attribute can be used as a short description, if
  you'd like to make description multiline.
- The optional "group" attribute can be set on each class in order to
  group the commands into logical groupings. Pass "group_order" into
  "add_commands()" to set the ordering of sub-commands in the help
  output.

New features:

- Sub-sub commands are now supported! Simply use a space within the
  "Command.name" attribute to get a subcommand. EG "task list" and "task
  add" could be names, and you'd get a "task" sub-sub-command with two
  further commands, "list" and "add". This is all automatically done.
  Subc actually creates each command with a hyphen too, so "task-list"
  would also be valid.

- Aliases work with the above, except that aliases must be set to the
  hyphenated name of the command, not the spaced name of the command.
  E.g, set an alias "tl -> task-list", not "tl -> task list".

- Shortest prefix matching is now compatible with aliases!

- Groups are now used to create help output for the root command. This
  allows a more organized help section.

- A new API is added (iter_commands()) to get all commands. This helps
  in case you want to do some logic on all commands, e.g. produce
  documentation.

- A new API is added to the sub-commands (simple_sub_parser()) which
  returns a basic ArgumentParser for a specific command. This could be
  useful to produce help output for just that command.

0.7.1
-----


M subc/__init__.py => subc/__init__.py +165 -30
@@ 100,13 100,17 @@ def _wrap_subparser_aliases(
    option._name_parser_map = option.choices


T = t.TypeVar("T", bound="Command")
F = t.TypeVar("F", bound=argparse.HelpFormatter)


class Command(ABC):
    """
    A simple class for implementing sub-commands in your command line
    application. Create a subclass for your app as follows:

        class MyCmd(subc.Command):
            pass
            rootname = "mycmd"

    Then, each command in your app can subclass this, implementing the three
    required fields:


@@ 124,9 128,24 @@ class Command(ABC):
            MyCmd.add_commands(parser)
            args = parser.parse_args()
            args.func(args)

    Optional properties of the command:

    - help_formatter_class: used to specify how argparse formats help
    - group: used to categorize commands into groups
    - help: used as a short description (fallback to description)
    - alias: used as an optional alias for this command (in case you rename it)
    """

    @abstractproperty
    def rootname(self) -> str:
        """The root command name (only needs to be defined on the parent)"""

    @property
    def help_formatter_class(self) -> t.Type[F]:
        return argparse.HelpFormatter

    @abstractproperty
    def name(self) -> str:
        """A field or property which is used for the command name argument"""



@@ 146,12 165,53 @@ class Command(ABC):
        return self.run()

    @classmethod
    def iter_commands(cls: t.Type[T]) -> t.Iterator[T]:
        """
        Iterate over all sub-commands of the root parser

        This function yields an instance subclass which subc will consider a
        "command" that is, only leaf classes in the hierarchy. You can use this
        if you want to do some sort of operation on each command, e.g.
        generating documentation.
        """
        subclasses = collections.deque(cls.__subclasses__())
        while subclasses:
            subcls = subclasses.popleft()
            this_node_subclasses = subcls.__subclasses__()
            if this_node_subclasses:
                # Assume that any class with children is not executable. Add
                # its children to the queue (BFS) but do not instantiate it.
                subclasses.extend(this_node_subclasses)
            else:
                yield subcls()

    def simple_sub_parser(self) -> argparse.ArgumentParser:
        """
        Return a simple argument parser for this sub-command

        This function returns an argument parser which could be used to parse
        arguments for this sub-command. It's not the same as the parser you get
        if you were to use the root command with add_commands() - but it's good
        if you'd like to only execute this one command, or if you'd like to
        create a parser for use by documentation generators like
        sphinx-argparse.
        """
        parser = argparse.ArgumentParser(
            prog=f"{self.rootname} {self.name}",
            description=self.description,
            formatter_class=self.help_formatter_class,
        )
        self.add_args(parser)
        return parser

    @classmethod
    def add_commands(
        cls,
        parser: argparse.ArgumentParser,
        default: t.Optional[str] = None,
        shortest_prefix: bool = False,
        cmd_aliases: t.Optional[t.Mapping[str, str]] = None,
        group_order: t.Optional[t.List[str]] = None,
    ) -> argparse.ArgumentParser:
        """
        Add all subcommands which are descendents of this class to parser.


@@ 173,52 233,127 @@ class Command(ABC):
        muscle memory for prefixes. However, if the set of sub-commands changes
        over time, then users may develop muscle-memory for a prefix which
        becomes ambiguous with a new command. Thus, it may be preferable to
        allow users to specify their own alias list. This function supports
        both methods of aliasing, or no aliasing.
        allow users to specify their own alias list. You can setup shortest
        prefix aliases and also user-specified aliases with this function, even
        simultaneously if you'd like.

        :param parser: Argument parser which is already created for this app
        :param default: Name of the command which should be executed if none is
          selected
        :param shortest_prefix: Enable shortest prefix command matching. If
          enabled, this takes priority over cmd_aliases.
        :param shortest_prefix: Enable shortest prefix command matching
        :param cmd_aliases: User-provided alias list in the form
          {"alias": "true name"}. This is only used if shortest_prefix is
          False.
          {"alias": "true name"}.
        :param group_order: Ordering of the groups in display
        :returns: the modified parser (this can be ignored)
        """
        default_set = False
        subparsers = parser.add_subparsers(title='sub-command')
        subclasses = collections.deque(cls.__subclasses__())
        to_add = []
        while subclasses:
            subcls = subclasses.popleft()
            this_node_subclasses = subcls.__subclasses__()
            if this_node_subclasses:
                # Assume that any class with children is not executable. Add
                # its children to the queue (BFS) but do not instantiate it.
                subclasses.extend(this_node_subclasses)
            else:
                to_add.append(subcls())
        subparsers = parser.add_subparsers(
            help=argparse.SUPPRESS, metavar="SUB-COMMAND",
        )
        parser.formatter_class = argparse.RawTextHelpFormatter
        to_add = list(cls.iter_commands())

        # Groups are for the help display, we will group the subcommands with
        # this and then output each one in a section.
        groups = collections.defaultdict(list)

        # Subcmds are for an added level of sub-command. For example, if subc is
        # used for "prog subcommand", then this would allow "prog level1 level2"
        # commands. We don't (yet) go further than this.
        subcmds = collections.defaultdict(list)

        # These are the names which actually would get considered for the unique
        # prefix operation. It will exclude the sub-sub-command names.
        names = []

        # These are the aliases defined by each command, e.g. in case they have
        # some other name for compatibility with previous versions of a tool.
        # This will be extended with the users cmd_aliases if provided.
        # ALIAS -> TRUE NAME
        aliases = {}

        max_len = 0

        if shortest_prefix:
            aliases = _unique_prefixes(c.name for c in to_add)
        elif cmd_aliases:
            aliases = collections.defaultdict(list)
            for name, target in cmd_aliases.items():
                aliases[target].append(name)
        else:
            aliases = collections.defaultdict(list)
        for cmd in to_add:
            base_name = cmd.name
            max_len = max(max_len, len(cmd.name))
            if " " in cmd.name:
                base_name = cmd.name.replace(" ", "-")
                sub, rem = cmd.name.split(" ", 1)
                subcmds[sub].append((rem, cmd))
            else:
                # Only include in shortest prefix mappings if it's not
                # a sub-sub-command.
                names.append(cmd.name)

            cmd_parser = subparsers.add_parser(
                cmd.name, description=cmd.description,
                help=getattr(cmd, "help", cmd.description),
                base_name,
                description=cmd.description,
                formatter_class=cmd.help_formatter_class,
            )
            cmd.add_args(cmd_parser)
            cmd_parser.set_defaults(func=cmd.base_run)
            if hasattr(cmd, "alias"):
                names.append(cmd.alias)
                aliases[cmd.alias] = base_name

            groups[getattr(cmd, "group", "")].append(cmd)

            if cmd.name == default:
                parser.set_defaults(func=cmd.base_run)
                default_set = True
        _wrap_subparser_aliases(subparsers, aliases)

        for subcmd, cmdlist in subcmds.items():
            subcmd_parser = subparsers.add_parser(subcmd)
            subcmd_subp = subcmd_parser.add_subparsers(
                title="sub-command", metavar="SUB-COMMAND",
            )
            sub_names = []
            names.append(subcmd)
            subcmd_parser.set_defaults(_sub=subcmd_parser)
            subcmd_parser.set_defaults(func=lambda ns: ns._sub.print_help())
            for name, cmd in cmdlist:
                sub_names.append(name)
                cmd_parser = subcmd_subp.add_parser(
                    name,
                    help=getattr(cmd, "help", cmd.description),
                    description=cmd.description,
                    formatter_class=cmd.help_formatter_class,
                )
                cmd.add_args(cmd_parser)
                cmd_parser.set_defaults(func=cmd.base_run)
            if shortest_prefix:
                sub_inv_aliases = _unique_prefixes(sub_names)
                _wrap_subparser_aliases(subcmd_subp, sub_inv_aliases)

        if cmd_aliases:
            names.extend(cmd_aliases)
            aliases.update(cmd_aliases)

        inv_aliases = collections.defaultdict(list)
        if shortest_prefix:
            inv_aliases.update(_unique_prefixes(names))
        for name, target in aliases.items():
            if " " in target:
                # allow alias to a subcommand
                target = target.replace(" ", "-")
            inv_aliases[target].append(name)
            inv_aliases[target].extend(inv_aliases.pop(name, []))
        _wrap_subparser_aliases(subparsers, inv_aliases)

        if not group_order:
            group_order = sorted(groups)
        lines = []
        for group in group_order:
            cmds = groups[group]
            if group:
                lines.append(group)
            for cmd in cmds:
                help = getattr(cmd, "help", cmd.description.strip())
                lines.append(f"{cmd.name.ljust(max_len)} {help}")
            lines.append("")

        parser.epilog = "\n".join(lines[:-1])

        if not default_set:
            def default_func(*args, **kwargs):