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 1 commit
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
297 changes: 297 additions & 0 deletions conans/cli/command.py
@@ -0,0 +1,297 @@
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)


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 ConanCommand(object):
czoido marked this conversation as resolved.
Show resolved Hide resolved
def __init__(self, method, group=None, **kwargs):
czoido marked this conversation as resolved.
Show resolved Hide resolved
self._formatters = {}
for kind, action in kwargs.items():
if callable(action):
self._formatters[kind] = action
czoido marked this conversation as resolved.
Show resolved Hide resolved
self._group = group or "Misc commands"
self._name = method.__name__.replace("_", "-")
self._method = method
self._doc = method.__doc__ or "Empty description"
czoido marked this conversation as resolved.
Show resolved Hide resolved
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

def run(self, *args, **kwargs):
conan_api = kwargs["conan_api"]
czoido marked this conversation as resolved.
Show resolved Hide resolved
info, formatter = self._method(*args, **kwargs)
czoido marked this conversation as resolved.
Show resolved Hide resolved
if info:
self._formatters[formatter](info, conan_api.out)

@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(**kwargs):
czoido marked this conversation as resolved.
Show resolved Hide resolved
def decorator(f):
cmd = ConanCommand(f, **kwargs)
return cmd

return decorator


class Command(object):
czoido marked this conversation as resolved.
Show resolved Hide resolved
"""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 = conan_api
czoido marked this conversation as resolved.
Show resolved Hide resolved
self._out = conan_api.out
self._groups = defaultdict(list)
self._commands = None

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

@property
def commands(self):
if self._commands is None:
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.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_", ""))
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")
czoido marked this conversation as resolved.
Show resolved Hide resolved

ret_code = SUCCESS
try:
try:
command_argument = args[0][0]
except IndexError: # No parameters
self.help_message()
return False
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 False

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

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(args[0][1:], conan_api=self.conan_api,
parser=self.commands[command_argument].parser,
commands=self.commands, groups=self.groups)

except KeyboardInterrupt as exc:
logger.error(exc)
ret_code = SUCCESS
except SystemExit as exc:
if exc.code != 0:
logger.error(exc)
self._out.error("Exiting with code: %d" % exc.code)
ret_code = exc.code
except ConanInvalidConfiguration as exc:
ret_code = ERROR_INVALID_CONFIGURATION
self._out.error(exc)
except ConanException as exc:
ret_code = ERROR_GENERAL
self._out.error(exc)
except Exception as exc:
import traceback
print(traceback.format_exc())
ret_code = ERROR_GENERAL
msg = exception_message_safe(exc)
self._out.error(msg)

return ret_code


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()
czoido marked this conversation as resolved.
Show resolved Hide resolved
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)

command = Command(conan_api)
error = command.run(args)
sys.exit(error)
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", 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, None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Command returns the data and the CLI output will print it. Here we cannot use the formatter. We are fooling ourselves.

try:
commands[args.command].run(["--help"], parser=commands[args.command].parser,
conan_api=conan_api)
except KeyError:
raise ConanException("Unknown command '%s'" % args.command)