1
0
mirror of https://github.com/openSUSE/osc.git synced 2024-12-29 11:16:14 +01:00
github.com_openSUSE_osc/osc/cmdln.py
2024-01-06 10:00:38 +01:00

302 lines
9.9 KiB
Python

"""
A modern, lightweight alternative to cmdln.py from https://github.com/trentm/cmdln
"""
import argparse
import inspect
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.insert(0, (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 name(name):
"""
Decorator to explicitly name a Cmdln subcommand.
Example:
class MyShell(cmdln.Cmdln):
@cmdln.name("cmd-with-dashes")
def do_cmd_with_dashes(self, subcmd, opts):
#...
"""
def decorate(f):
f.name = name
return f
return decorate
def hide(value=True):
"""
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 = bool(value)
return f
return decorate
class HelpFormatter(argparse.RawDescriptionHelpFormatter):
def _format_action(self, action):
if isinstance(action, argparse._SubParsersAction):
parts = []
subactions = action._get_subactions()
subactions.sort(key=lambda x: x.metavar)
for i in 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] <command> [--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()
self.add_global_options(self.argparser)
# 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
cmd_name = getattr(cmd_func, "name", cmd_name)
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
if cmd_func.__doc__:
# 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()
else:
help_text = ""
help_desc = ""
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,
conflict_handler="resolve",
)
# add hidden copy of global options so they can be used in any place
self.add_global_options(subparser, suppress=True)
# add sub-command options, overriding hidden copies of global options if needed (due to conflict_handler="resolve")
for option_args, option_kwargs in options:
subparser.add_argument(*option_args, **option_kwargs)
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 add_global_options(self, parser, suppress=False):
"""
Add options to the main argument parser and all subparsers.
"""
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.args = self.argparser.parse_known_args(argv[1:])
unrecognized = [i for i in self.args if i.startswith("-")]
if unrecognized:
self.argparser.error(f"unrecognized arguments: {' '.join(unrecognized)}")
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
sig = inspect.signature(cmd)
arg_names = list(sig.parameters.keys())
if arg_names == ["subcmd", "opts"]:
# positional args specified manually via @cmdln.option
if self.args:
self.argparser.error(f"unrecognized arguments: {' '.join(self.args)}")
cmd(self.options.command, self.options)
elif arg_names == ["subcmd", "opts", "args"]:
# positional args are the remaining (unrecognized) args
cmd(self.options.command, self.options, *self.args)
else:
# positional args are the remaining (unrecongnized) args
# and the do_* handler takes other arguments than "subcmd", "opts", "args"
import warnings
warnings.warn(
f"do_{self.options.command}() handler has deprecated signature. "
f"It takes the following args: {arg_names}, while it should be taking ['subcmd', 'opts'] "
f"and handling positional arguments explicitly via @cmdln.option.",
FutureWarning
)
try:
cmd(self.options.command, self.options, *self.args)
except TypeError as e:
if e.args[0].startswith("do_"):
sys.exit(str(e))
raise
@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