Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cli 2.0: Add commands management #7278

Merged
merged 20 commits into from Jul 15, 2020
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
242 changes: 242 additions & 0 deletions conans/cli/cli.py
@@ -0,0 +1,242 @@
import argparse
import os
import signal
import sys
import textwrap
from collections import defaultdict
from difflib import get_close_matches
import importlib
import pkgutil

from conans import __version__ as client_version
from conans.util.env_reader import get_env
from conans.client.conan_api import Conan
from conans.errors import ConanException, ConanInvalidConfiguration, ConanMigrationError
from conans.util.files import exception_message_safe
from conans.util.log import logger

# Exit codes for conan command:
SUCCESS = 0 # 0: Success (done)
ERROR_GENERAL = 1 # 1: General ConanException error (done)
ERROR_MIGRATION = 2 # 2: Migration error
USER_CTRL_C = 3 # 3: Ctrl+C
USER_CTRL_BREAK = 4 # 4: Ctrl+Break
ERROR_SIGTERM = 5 # 5: SIGTERM
ERROR_INVALID_CONFIGURATION = 6 # 6: Invalid configuration (done)
czoido marked this conversation as resolved.
Show resolved Hide resolved


class Extender(argparse.Action):
"""Allows using the same flag several times in command and creates a list with the values.
For example:
conan install MyPackage/1.2@user/channel -o qt:value -o mode:2 -s cucumber:true
It creates:
options = ['qt:value', 'mode:2']
settings = ['cucumber:true']
"""

def __call__(self, parser, namespace, values, option_strings=None): # @UnusedVariable
# Need None here incase `argparse.SUPPRESS` was supplied for `dest`
dest = getattr(namespace, self.dest, None)
if not hasattr(dest, 'extend') or dest == self.default:
dest = []
setattr(namespace, self.dest, dest)
# if default isn't set to None, this method might be called
# with the default as `values` for other arguments which
# share this destination.
parser.set_defaults(**{self.dest: None})

if isinstance(values, str):
dest.append(values)
elif values:
try:
dest.extend(values)
except ValueError:
dest.append(values)


class OnceArgument(argparse.Action):
"""Allows declaring a parameter that can have only one value, by default argparse takes the
latest declared and it's very confusing.
"""

def __call__(self, parser, namespace, values, option_string=None):
if getattr(namespace, self.dest) is not None and self.default is None:
msg = '{o} can only be specified once'.format(o=option_string)
raise argparse.ArgumentError(None, msg)
setattr(namespace, self.dest, values)


class SmartFormatter(argparse.HelpFormatter):

def _fill_text(self, text, width, indent):
text = textwrap.dedent(text)
return ''.join(indent + line for line in text.splitlines(True))


class Cli(object):
"""A single command of the conan application, with all the first level commands. Manages the
parsing of parameters and delegates functionality to the conan python api. It can also show the
help of the tool.
"""

def __init__(self, conan_api):
assert isinstance(conan_api, Conan), "Expected 'Conan' type, got '{}'".format(
type(conan_api))
self._conan_api = conan_api
self._out = conan_api.out
self._groups = defaultdict(list)
self._commands = {}
conan_commands_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "commands")
for module in pkgutil.iter_modules([conan_commands_path]):
self._add_command("conans.cli.commands.{}".format(module.name), module.name)
if get_env("CONAN_USER_COMMANDS", default=False):
user_commands_path = os.path.join(self._conan_api.cache_folder, "commands")
sys.path.append(user_commands_path)
for module in pkgutil.iter_modules([user_commands_path]):
if module.name.startswith("cmd_"):
self._add_command(module.name, module.name.replace("cmd_", ""))

def _add_command(self, import_path, method_name):
try:
command_wrapper = getattr(importlib.import_module(import_path), method_name)
if command_wrapper.doc:
self._commands[command_wrapper.name] = command_wrapper
self._groups[command_wrapper.group].append(command_wrapper.name)
except AttributeError:
raise ConanException("There is no {} method defined in {}".format(method_name,
import_path))

@property
def conan_api(self):
return self._conan_api

@property
def commands(self):
return self._commands

@property
def groups(self):
return self._groups

def _print_similar(self, command):
""" Looks for similar commands and prints them if found.
"""
matches = get_close_matches(
word=command, possibilities=self.commands.keys(), n=5, cutoff=0.75)

if len(matches) == 0:
return

if len(matches) > 1:
self._out.writeln("The most similar commands are")
else:
self._out.writeln("The most similar command is")

for match in matches:
self._out.writeln(" %s" % match)

self._out.writeln("")

def help_message(self):
self.commands["help"].method(self.conan_api, self.commands, self.groups)

def run(self, *args):
""" Entry point for executing commands, dispatcher to class
methods
"""
version = sys.version_info
if version.major == 2 or version.minor <= 4:
raise ConanException(
"Unsupported Python version. Minimum required version is Python 3.5")

try:
command_argument = args[0][0]
except IndexError: # No parameters
self.help_message()
return SUCCESS
try:
command = self.commands[command_argument]
except KeyError as exc:
if command_argument in ["-v", "--version"]:
self._out.success("Conan version %s" % client_version)
return SUCCESS

if command_argument in ["-h", "--help"]:
self.help_message()
return SUCCESS

self._out.writeln(
"'%s' is not a Conan command. See 'conan --help'." % command_argument)
self._out.writeln("")
self._print_similar(command_argument)
raise ConanException("Unknown command %s" % str(exc))

command.run(self.conan_api, args[0][1:], parser=self.commands[command_argument].parser,
commands=self.commands, groups=self.groups)

return SUCCESS


def main(args):
""" main entry point of the conan application, using a Command to
parse parameters

Exit codes for conan command:

0: Success (done)
1: General ConanException error (done)
2: Migration error
3: Ctrl+C
4: Ctrl+Break
5: SIGTERM
6: Invalid configuration (done)
"""
try:
conan_api, _, _ = Conan.factory() # FIXME: Conan factory will be removed in Conan 2.0
except ConanMigrationError: # Error migrating
sys.exit(ERROR_MIGRATION)
except ConanException as e:
sys.stderr.write("Error in Conan initialization: {}".format(e))
sys.exit(ERROR_GENERAL)

def ctrl_c_handler(_, __):
print('You pressed Ctrl+C!')
sys.exit(USER_CTRL_C)

def sigterm_handler(_, __):
print('Received SIGTERM!')
sys.exit(ERROR_SIGTERM)

def ctrl_break_handler(_, __):
print('You pressed Ctrl+Break!')
sys.exit(USER_CTRL_BREAK)

signal.signal(signal.SIGINT, ctrl_c_handler)
signal.signal(signal.SIGTERM, sigterm_handler)

if sys.platform == 'win32':
signal.signal(signal.SIGBREAK, ctrl_break_handler)

cli = Cli(conan_api)

try:
exit_error = cli.run(args)
except SystemExit as exc:
if exc.code != 0:
logger.error(exc)
conan_api.out.error("Exiting with code: %d" % exc.code)
exit_error = exc.code
except ConanInvalidConfiguration as exc:
exit_error = ERROR_INVALID_CONFIGURATION
conan_api.out.error(exc)
except ConanException as exc:
exit_error = ERROR_GENERAL
conan_api.out.error(exc)
except Exception as exc:
import traceback
print(traceback.format_exc())
exit_error = ERROR_GENERAL
msg = exception_message_safe(exc)
conan_api.out.error(msg)

sys.exit(exit_error)
72 changes: 72 additions & 0 deletions conans/cli/command.py
@@ -0,0 +1,72 @@
import argparse

from conans.cli.cli import SmartFormatter, OnceArgument
from conans.errors import ConanException


class ConanCommand(object):
czoido marked this conversation as resolved.
Show resolved Hide resolved
def __init__(self, method, group, formatters=None):
self._formatters = {}
for kind, action in formatters.items():
if callable(action):
self._formatters[kind] = action
czoido marked this conversation as resolved.
Show resolved Hide resolved
else:
raise ConanException("Invalid formatter for {}. The formatter must be"
"a valid function".format(kind))

self._group = group or "Misc commands"
self._name = method.__name__.replace("_", "-")
self._method = method
if method.__doc__:
self._doc = method.__doc__
else:
raise ConanException("No documentation string defined for command: '{}'. Conan "
"commands should provide a documentation string explaining "
"its use briefly.".format(self._name))
self._parser = argparse.ArgumentParser(description=self._doc,
prog="conan {}".format(self._name),
formatter_class=SmartFormatter)
jgsogo marked this conversation as resolved.
Show resolved Hide resolved
formatters_list = list(self._formatters.keys())
if self._formatters:
self._output_help_message = "Select the output format: {}"\
.format(", ".join(formatters_list))

self._parser.add_argument('-o', '--output', default="cli", choices=formatters_list,
action=OnceArgument, help=self._output_help_message)
czoido marked this conversation as resolved.
Show resolved Hide resolved

def run(self, conan_api, *args, **kwargs):
try:
info = self._method(*args, conan_api=conan_api, **kwargs)
parser_args = self._parser.parse_args(*args)
if info:
self._formatters[parser_args.output](info, conan_api.out)
except Exception:
raise

@property
def group(self):
return self._group

@property
def name(self):
return self._name

@property
def method(self):
return self._method

@property
def doc(self):
return self._doc

@property
def parser(self):
return self._parser


def conan_command(group, formatters=None):
def decorator(f):
cmd = ConanCommand(f, group, formatters)
return cmd

return decorator
56 changes: 56 additions & 0 deletions conans/cli/commands/help.py
@@ -0,0 +1,56 @@
import textwrap

from conans.client.output import Color
from conans.errors import ConanException
from conans.cli.command import conan_command


def output_help_cli(out, commands, groups):
"""
Prints a summary of all commands.
"""
max_len = max((len(c) for c in commands)) + 1
fmt = ' %-{}s'.format(max_len)

for group_name, comm_names in groups.items():
out.writeln(group_name, Color.BRIGHT_MAGENTA)
for name in comm_names:
# future-proof way to ensure tabular formatting
out.write(fmt % name, Color.GREEN)

# Help will be all the lines up to the first empty one
docstring_lines = commands[name].doc.split('\n')
start = False
data = []
for line in docstring_lines:
line = line.strip()
if not line:
if start:
break
start = True
continue
data.append(line)

txt = textwrap.fill(' '.join(data), 80, subsequent_indent=" " * (max_len + 2))
out.writeln(txt)

out.writeln("")
out.writeln('Conan commands. Type "conan <command> -h" for help', Color.BRIGHT_YELLOW)


@conan_command(group="Misc commands", formatters={"cli": output_help_cli})
def help(*args, conan_api, parser, commands, groups, **kwargs):
"""
Shows help for a specific command.
"""

parser.add_argument("command", help='command', nargs="?")
args = parser.parse_args(*args)
if not args.command:
output_help_cli(conan_api.out, commands, groups)
return None
try:
commands[args.command].run(["--help"], parser=commands[args.command].parser,
conan_api=conan_api)
except KeyError:
raise ConanException("Unknown command '%s'" % args.command)