1
0
mirror of https://github.com/openSUSE/osc.git synced 2025-02-21 17:52:14 +01:00
github.com_openSUSE_osc/osc/commandline_common.py

280 lines
9.7 KiB
Python

import argparse
import copy
import importlib
import inspect
import os
import pkgutil
import sys
import textwrap
from typing import List
from . import cmdln
# python3.6 requires reading sys.real_prefix to detect virtualenv
IN_VENV = getattr(sys, "real_prefix", sys.base_prefix) != sys.prefix
class OscArgumentParser(argparse.ArgumentParser):
def _get_formatter(self):
# cache formatter to speed things a little bit up
if not hasattr(self, "_formatter"):
self._formatter = self.formatter_class(prog=self.prog)
return self._formatter
def add_argument(self, *args, **kwargs):
# remember added arguments so we can add them to subcommands easily
if not hasattr(self, "_added_arguments"):
self._added_arguments = []
self._added_arguments.append((args, kwargs))
super().add_argument(*args, **kwargs)
class Command:
#: Name of the command as used in the argument parser.
name: str = None
#: Optional aliases to the command.
aliases: List[str] = []
#: Whether the command is hidden from help.
#: Defaults to ``False``.
hidden: bool = False
#: Name of the parent command class.
#: Can be prefixed if the parent comes from a different location,
#: for example ``osc.commands.<ClassName>`` when extending osc command with a plugin.
#: See ``OscMainCommand.MODULES`` for available prefixes.
parent: str = None
def __init__(self, full_name, parent=None):
self.full_name = full_name
self.parent = parent
self.subparsers = None
if not self.name:
raise ValueError(f"Command '{self.full_name}' has no 'name' set")
if parent:
self.parser = self.parent.subparsers.add_parser(
self.name,
aliases=self.aliases,
help=self.get_help(),
description=self.get_description(),
formatter_class=cmdln.HelpFormatter,
conflict_handler="resolve",
prog=f"{self.main_command.name} [global opts] {self.name}",
)
self.parser.set_defaults(_selected_command=self)
else:
self.parser = OscArgumentParser(
description=self.get_description(),
formatter_class=cmdln.HelpFormatter,
usage="%(prog)s [global opts] <command> [--help] [opts] [args]",
)
if self.parent:
for arg_args, arg_kwargs in self.parent.parser._added_arguments:
if not arg_args:
continue
if not arg_args[0].startswith("-"):
continue
if "--help" in arg_args:
continue
arg_kwargs = arg_kwargs.copy()
arg_kwargs["help"] = argparse.SUPPRESS
arg_kwargs["default"] = argparse.SUPPRESS
self.parser.add_argument(*arg_args, **arg_kwargs)
self.init_arguments()
def __repr__(self):
return f"<osc plugin {self.full_name} at {self.__hash__():#x}>"
def get_help(self):
"""
Return the help text of the command.
The first line of the docstring is returned by default.
"""
if self.hidden:
return argparse.SUPPRESS
if not self.__doc__:
return ""
help_lines = self.__doc__.strip().splitlines()
if not help_lines:
return ""
return help_lines[0]
def get_description(self):
"""
Return the description of the command.
The docstring without the first line is returned by default.
"""
if not self.__doc__:
return ""
help_lines = self.__doc__.strip().splitlines()
if not help_lines:
return ""
# skip the first line that contains help text
help_lines.pop(0)
# remove any leading empty lines
while help_lines and not help_lines[0]:
help_lines.pop(0)
result = "\n".join(help_lines)
result = textwrap.dedent(result)
return result
@property
def main_command(self):
"""
Return reference to the main command that represents the executable
and contains the main instance of ArgumentParser.
"""
if not self.parent:
return self
return self.parent.main_command
def add_argument(self, *args, **kwargs):
"""
Add a new argument to the command's argument parser.
See `argparse <https://docs.python.org/3/library/argparse.html>`_ documentation for allowed parameters.
"""
self.parser.add_argument(*args, **kwargs)
def init_arguments(self):
"""
Override to add arguments to the argument parser.
.. note::
Make sure you're adding arguments only by calling ``self.add_argument()``.
Using ``self.parser.add_argument()`` directly is not recommended
because it disables argument intermixing.
"""
def run(self, args):
"""
Override to implement the command functionality.
.. note::
``args.positional_args`` is a list containing any unknown (unparsed) positional arguments.
.. note::
Consider moving any reusable code into a library,
leaving the command-line code only a thin wrapper on top of it.
If the code is generic enough, it should be added to osc directly.
In such case don't hesitate to open an `issue <https://github.com/openSUSE/osc/issues>`_.
"""
raise NotImplementedError()
def register(self, command_class, command_full_name):
if not self.subparsers:
# instantiate subparsers on first use
self.subparsers = self.parser.add_subparsers(dest="command", title="commands")
# Check for parser conflicts.
# This is how Python 3.11+ behaves by default.
if command_class.name in self.subparsers._name_parser_map:
raise argparse.ArgumentError(self.subparsers, f"conflicting subparser: {command_class.name}")
for alias in command_class.aliases:
if alias in self.subparsers._name_parser_map:
raise argparse.ArgumentError(self.subparsers, f"conflicting subparser alias: {alias}")
command = command_class(command_full_name, parent=self)
return command
class MainCommand(Command):
MODULES = ()
def __init__(self):
super().__init__(self.__class__.__name__)
self.command_classes = {}
self.download_progress = None
def post_parse_args(self, args):
pass
def run(self, args):
cmd = getattr(args, "_selected_command", None)
if not cmd:
self.parser.error("Please specify a command")
self.post_parse_args(args)
return cmd.run(args)
def load_command(self, cls, module_prefix):
mod_cls_name = f"{module_prefix}.{cls.__name__}"
parent_name = getattr(cls, "parent", None)
if parent_name:
# allow relative references to classes in the the same module/directory
if "." not in parent_name:
parent_name = f"{module_prefix}.{parent_name}"
try:
parent = self.main_command.command_classes[parent_name]
except KeyError:
msg = f"Failed to load command class '{mod_cls_name}' because it references parent '{parent_name}' that doesn't exist"
print(msg, file=sys.stderr)
return None
cmd = parent.register(cls, mod_cls_name)
else:
cmd = self.main_command.register(cls, mod_cls_name)
cmd.full_name = mod_cls_name
self.main_command.command_classes[mod_cls_name] = cmd
return cmd
def load_commands(self):
if IN_VENV:
from . import output # pylint: disable=import-outside-toplevel
output.print_msg("Running in virtual environment, skipping loading plugins installed outside the virtual environment.", print_to="debug")
for module_prefix, module_path in self.MODULES:
module_path = os.path.expanduser(module_path)
# some plugins have their modules installed next to them instead of site-packages
if module_path not in sys.path:
sys.path.append(module_path)
for loader, module_name, _ in pkgutil.iter_modules(path=[module_path]):
full_name = f"{module_prefix}.{module_name}"
spec = loader.find_spec(full_name)
mod = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(mod)
except Exception as e: # pylint: disable=broad-except
msg = f"Failed to load commands from module '{full_name}': {e}"
print(msg, file=sys.stderr)
continue
for name in dir(mod):
if name.startswith("_"):
continue
cls = getattr(mod, name)
if not inspect.isclass(cls):
continue
if not issubclass(cls, Command):
continue
if cls.__module__ != full_name:
# skip classes that weren't defined directly in the loaded plugin module
continue
self.load_command(cls, module_prefix)
def parse_args(self, *args, **kwargs):
namespace, unknown_args = self.parser.parse_known_args(*args, **kwargs)
unrecognized = [i for i in unknown_args if i.startswith("-")]
if unrecognized:
self.parser.error(f"unrecognized arguments: " + " ".join(unrecognized))
namespace.positional_args = list(unknown_args)
return namespace