@@ 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
-----
@@ 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):