""" A modern, lightweight alternative to cmdln.py from https://github.com/trentm/cmdln """ import argparse import sys def option(*args, **kwargs): """ Decorator to add an option to the optparser argument of a Cmdln subcommand. Example: class MyShell(cmdln.Cmdln): @cmdln.option("-f", "--force", help="force removal") def do_remove(self, subcmd, opts, *args): #... """ def decorate(f): if not hasattr(f, "options"): f.options = [] new_args = [i for i in args if i] f.options.append((new_args, kwargs)) return f return decorate def alias(*aliases): """ Decorator to add aliases for Cmdln.do_* command handlers. Example: class MyShell(cmdln.Cmdln): @cmdln.alias("!", "sh") def do_shell(self, argv): #...implement 'shell' command """ def decorate(f): if not hasattr(f, "aliases"): f.aliases = [] f.aliases += aliases return f return decorate def hide(): """ For obsolete calls, hide them in help listings. Example: class MyShell(cmdln.Cmdln): @cmdln.hide() def do_shell(self, argv): #...implement 'shell' command """ def decorate(f): f.hidden = True return f return decorate class HelpFormatter(argparse.RawDescriptionHelpFormatter): def _format_action(self, action): if isinstance(action, argparse._SubParsersAction): parts = [] for i in action._get_subactions(): if i.help == argparse.SUPPRESS: # don't display commands with suppressed help continue if len(i.metavar) > 20: parts.append("%*s%-21s" % (self._current_indent, "", i.metavar)) parts.append("%*s %s" % (self._current_indent + 21, "", i.help)) else: parts.append("%*s%-21s %s" % (self._current_indent, "", i.metavar, i.help)) return "\n".join(parts) return super()._format_action(action) class Cmdln: def get_argparser_usage(self): return "%(prog)s [global opts] [--help] [opts] [args]" def get_subcommand_prog(self, subcommand): return f"{self.argparser.prog} [global opts] {subcommand}" def _remove_leading_spaces_from_text(self, text): lines = text.splitlines() lines = self._remove_leading_spaces_from_lines(lines) return "\n".join(lines) def _remove_leading_spaces_from_lines(self, lines): # compute the indentation (leading spaces) in the docstring leading_spaces = 0 for line in lines: line_leading_spaces = len(line) - len(line.lstrip(' ')) if leading_spaces == 0: leading_spaces = line_leading_spaces leading_spaces = min(leading_spaces, line_leading_spaces) # dedent the lines (remove leading spaces) lines = [line[leading_spaces:] for line in lines] return lines def create_argparser(self): """ Create `.argparser` and `.subparsers`. Override this method to replace them with your own. """ self.argparser = argparse.ArgumentParser( usage=self.get_argparser_usage(), description=self._remove_leading_spaces_from_text(self.__doc__), formatter_class=HelpFormatter, ) self.subparsers = self.argparser.add_subparsers( title="commands", dest="command", ) self.pre_argparse() # map command name to `do_*` function that runs the command self.cmd_map = {} # map aliases back to the command names self.alias_to_cmd_name_map = {} for attr in dir(self): if not attr.startswith("do_"): continue cmd_name = attr[3:] cmd_func = getattr(self, attr) # extract data from the function options = getattr(cmd_func, "options", []) aliases = getattr(cmd_func, "aliases", []) hidden = getattr(cmd_func, "hidden", False) # map command name and aliases to the function self.cmd_map[cmd_name] = cmd_func self.alias_to_cmd_name_map[cmd_name] = cmd_name for i in aliases: self.cmd_map[i] = cmd_func self.alias_to_cmd_name_map[i] = cmd_name # split doctext into lines, allow the first line to start at a new line help_lines = cmd_func.__doc__.lstrip().splitlines() # use the first line as help text help_text = help_lines.pop(0) # use the remaining lines as description help_lines = self._remove_leading_spaces_from_lines(help_lines) help_desc = "\n".join(help_lines) help_desc = help_desc.strip() if hidden: help_text = argparse.SUPPRESS subparser = self.subparsers.add_parser( cmd_name, aliases=aliases, help=help_text, description=help_desc, prog=self.get_subcommand_prog(cmd_name), formatter_class=HelpFormatter ) for option_args, option_kwargs in options: subparser.add_argument(*option_args, **option_kwargs) # HACK: inject 'args' to all commands so we don't have to decorate all of them subparser.add_argument('args', nargs='*') def argparse_error(self, *args, **kwargs): """ Raise an argument parser error. Automatically pick the right parser for the main program or a subcommand. """ if not self.options.command: parser = self.argparser else: parser = self.subparsers._name_parser_map.get(self.options.command, self.argparser) parser.error(*args, **kwargs) def pre_argparse(self): """ Hook method executed after `.main()` creates `.argparser` instance and before `parse_args()` is called. """ pass def post_argparse(self): """ Hook method executed after `.main()` calls `parse_args()`. When called, `.options` and `.args` hold the results of `parse_args()`. """ pass def main(self, argv=None): if argv is None: argv = sys.argv else: argv = argv[:] # don't modify caller's list self.create_argparser() self.options = self.argparser.parse_args(argv[1:]) self.args = getattr(self.options, "args", []) self.post_argparse() if not self.options.command: self.argparser.error("Please specify a command") # find the `do_*` function to call by its name cmd = self.cmd_map[self.options.command] # run the command with parsed args cmd(self.options.command, self.options, *self.args) @alias("?") def do_help(self, subcmd, opts, *args): """ Give detailed help on a specific sub-command usage: %(prog)s [SUBCOMMAND] """ if not args: self.argparser.print_help() return for action in self.argparser._actions: if not isinstance(action, argparse._SubParsersAction): continue for choice, subparser in action.choices.items(): if choice == args[0]: subparser.print_help() return