blob: 515c06a3390567ae7a1458e0dfa76320fa319501 [file] [log] [blame]
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Enhancements and ARIA-specific conveniences for `Click <http://click.pocoo.org>`__.
"""
import os
import sys
import difflib
import traceback
import inspect
from functools import wraps
import click
from ..env import (
env,
logger
)
from .. import defaults
from .. import helptexts
from ..ascii_art import ARIA_ASCII_ART
from ..inputs import inputs_to_dict
from ... import __version__
from ...utils.exceptions import get_exception_as_string
CLICK_CONTEXT_SETTINGS = dict(
help_option_names=['-h', '--help'],
token_normalize_func=lambda param: param.lower())
class MutuallyExclusiveOption(click.Option):
def __init__(self, *args, **kwargs):
self.mutually_exclusive = set(kwargs.pop('mutually_exclusive', tuple()))
self.mutuality_description = kwargs.pop('mutuality_description',
', '.join(self.mutually_exclusive))
self.mutuality_error = kwargs.pop('mutuality_error',
helptexts.DEFAULT_MUTUALITY_ERROR_MESSAGE)
if self.mutually_exclusive:
help = kwargs.get('help', '')
kwargs['help'] = '{0}. {1}'.format(help, self._message)
super(MutuallyExclusiveOption, self).__init__(*args, **kwargs)
def handle_parse_result(self, ctx, opts, args):
if (self.name in opts) and self.mutually_exclusive.intersection(opts):
raise click.UsageError('Illegal usage: {0}'.format(self._message))
return super(MutuallyExclusiveOption, self).handle_parse_result(ctx, opts, args)
@property
def _message(self):
return '{0} be used together with {1} ({2}).'.format(
'{0} cannot'.format(', '.join(self.opts)) if hasattr(self, 'opts') else 'Cannot',
self.mutuality_description,
self.mutuality_error)
def mutually_exclusive_option(*param_decls, **attrs):
"""
Decorator for mutually exclusive options.
This decorator works similarly to `click.option`, but supports an extra ``mutually_exclusive``
argument, which is a list of argument names with which the option is mutually exclusive.
You can optionally also supply ``mutuality_description`` and ``mutuality_error`` to override the
default messages.
NOTE: All mutually exclusive options must use this. It's not enough to use it in just one of the
options.
"""
# NOTE: This code is copied and slightly modified from click.decorators.option and
# click.decorators._param_memo. Unfortunately, using click's ``cls`` parameter support does not
# work as is with extra decorator arguments.
def decorator(func):
if 'help' in attrs:
attrs['help'] = inspect.cleandoc(attrs['help'])
param = MutuallyExclusiveOption(param_decls, **attrs)
if not hasattr(func, '__click_params__'):
func.__click_params__ = []
func.__click_params__.append(param)
return func
return decorator
def show_version(ctx, param, value):
if not value:
return
logger.info('{0} v{1}'.format(ARIA_ASCII_ART, __version__))
ctx.exit()
def inputs_callback(ctx, param, value):
"""
Allow to pass any inputs we provide to a command as processed inputs instead of having to call
``inputs_to_dict`` inside the command.
``@aria.options.inputs`` already calls this callback so that every time you use the option it
returns the inputs as a dictionary.
"""
if not value:
return {}
return inputs_to_dict(value)
def set_verbosity_level(ctx, param, value):
if not value:
return
env.logging.verbosity_level = value
def set_cli_except_hook():
def recommend(possible_solutions):
logger.info('Possible solutions:')
for solution in possible_solutions:
logger.info(' - {0}'.format(solution))
def new_excepthook(tpe, value, trace):
if env.logging.is_high_verbose_level():
# log error including traceback
logger.error(get_exception_as_string(tpe, value, trace))
else:
# write the full error to the log file
with open(env.logging.log_file, 'a') as log_file:
traceback.print_exception(
etype=tpe,
value=value,
tb=trace,
file=log_file)
# print only the error message
print value
if hasattr(value, 'possible_solutions'):
recommend(getattr(value, 'possible_solutions'))
sys.excepthook = new_excepthook
def pass_logger(func):
"""
Simply passes the logger to a command.
"""
# Wraps here makes sure the original docstring propagates to click
@wraps(func)
def wrapper(*args, **kwargs):
return func(logger=logger, *args, **kwargs)
return wrapper
def pass_plugin_manager(func):
"""
Simply passes the plugin manager to a command.
"""
# Wraps here makes sure the original docstring propagates to click
@wraps(func)
def wrapper(*args, **kwargs):
return func(plugin_manager=env.plugin_manager, *args, **kwargs)
return wrapper
def pass_model_storage(func):
"""
Simply passes the model storage to a command.
"""
# Wraps here makes sure the original docstring propagates to click
@wraps(func)
def wrapper(*args, **kwargs):
return func(model_storage=env.model_storage, *args, **kwargs)
return wrapper
def pass_resource_storage(func):
"""
Simply passes the resource storage to a command.
"""
# Wraps here makes sure the original docstring propagates to click
@wraps(func)
def wrapper(*args, **kwargs):
return func(resource_storage=env.resource_storage, *args, **kwargs)
return wrapper
def pass_context(func):
"""
Make click context ARIA specific.
This exists purely for aesthetic reasons, otherwise some decorators are called
``@click.something`` instead of ``@aria.something``.
"""
return click.pass_context(func)
class AliasedGroup(click.Group):
def __init__(self, *args, **kwargs):
self.max_suggestions = kwargs.pop("max_suggestions", 3)
self.cutoff = kwargs.pop("cutoff", 0.5)
super(AliasedGroup, self).__init__(*args, **kwargs)
def get_command(self, ctx, cmd_name):
cmd = click.Group.get_command(self, ctx, cmd_name)
if cmd is not None:
return cmd
matches = \
[x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
if not matches:
return None
elif len(matches) == 1:
return click.Group.get_command(self, ctx, matches[0])
ctx.fail('Too many matches: {0}'.format(', '.join(sorted(matches))))
def resolve_command(self, ctx, args):
"""
Override clicks ``resolve_command`` method and appends *Did you mean ...* suggestions to the
raised exception message.
"""
try:
return super(AliasedGroup, self).resolve_command(ctx, args)
except click.exceptions.UsageError as error:
error_msg = str(error)
original_cmd_name = click.utils.make_str(args[0])
matches = difflib.get_close_matches(
original_cmd_name,
self.list_commands(ctx),
self.max_suggestions,
self.cutoff)
if matches:
error_msg += '{0}{0}Did you mean one of these?{0} {1}'.format(
os.linesep,
'{0} '.format(os.linesep).join(matches, ))
raise click.exceptions.UsageError(error_msg, error.ctx)
def group(name):
"""
Allow to create a group with a default click context and a class for Click's ``didyoueamn``
without having to repeat it for every group.
"""
return click.group(
name=name,
context_settings=CLICK_CONTEXT_SETTINGS,
cls=AliasedGroup)
def command(*args, **kwargs):
"""
Make Click commands ARIA specific.
This exists purely for aesthetic reasons, otherwise some decorators are called
``@click.something`` instead of ``@aria.something``.
"""
return click.command(*args, **kwargs)
def argument(*args, **kwargs):
"""
Make Click arguments specific to ARIA.
This exists purely for aesthetic reasons, otherwise some decorators are called
``@click.something`` instead of ``@aria.something``
"""
return click.argument(*args, **kwargs)
class Options(object):
def __init__(self):
"""
The options API is nicer when you use each option by calling ``@aria.options.some_option``
instead of ``@aria.some_option``.
Note that some options are attributes and some are static methods. The reason for that is
that we want to be explicit regarding how a developer sees an option. If it can receive
arguments, it's a method - if not, it's an attribute.
"""
self.version = click.option(
'--version',
is_flag=True,
callback=show_version,
expose_value=False,
is_eager=True,
help=helptexts.VERSION)
self.json_output = click.option(
'--json-output',
is_flag=True,
help=helptexts.JSON_OUTPUT)
self.dry_execution = click.option(
'--dry',
is_flag=True,
help=helptexts.DRY_EXECUTION)
self.reset_config = click.option(
'--reset-config',
is_flag=True,
help=helptexts.RESET_CONFIG)
self.descending = click.option(
'--descending',
required=False,
is_flag=True,
default=defaults.SORT_DESCENDING,
help=helptexts.DESCENDING)
self.service_template_filename = click.option(
'-n',
'--service-template-filename',
default=defaults.SERVICE_TEMPLATE_FILENAME,
help=helptexts.SERVICE_TEMPLATE_FILENAME)
self.service_template_mode_full = mutually_exclusive_option(
'-f',
'--full',
'mode_full',
mutually_exclusive=('mode_types',),
is_flag=True,
help=helptexts.SHOW_FULL,
mutuality_description='-t, --types',
mutuality_error=helptexts.MODE_MUTUALITY_ERROR_MESSAGE)
self.service_mode_full = mutually_exclusive_option(
'-f',
'--full',
'mode_full',
mutually_exclusive=('mode_graph',),
is_flag=True,
help=helptexts.SHOW_FULL,
mutuality_description='-g, --graph',
mutuality_error=helptexts.MODE_MUTUALITY_ERROR_MESSAGE)
self.mode_types = mutually_exclusive_option(
'-t',
'--types',
'mode_types',
mutually_exclusive=('mode_full',),
is_flag=True,
help=helptexts.SHOW_TYPES,
mutuality_description='-f, --full',
mutuality_error=helptexts.MODE_MUTUALITY_ERROR_MESSAGE)
self.mode_graph = mutually_exclusive_option(
'-g',
'--graph',
'mode_graph',
mutually_exclusive=('mode_full',),
is_flag=True,
help=helptexts.SHOW_GRAPH,
mutuality_description='-f, --full',
mutuality_error=helptexts.MODE_MUTUALITY_ERROR_MESSAGE)
self.format_json = mutually_exclusive_option(
'-j',
'--json',
'format_json',
mutually_exclusive=('format_yaml',),
is_flag=True,
help=helptexts.SHOW_JSON,
mutuality_description='-y, --yaml',
mutuality_error=helptexts.FORMAT_MUTUALITY_ERROR_MESSAGE)
self.format_yaml = mutually_exclusive_option(
'-y',
'--yaml',
'format_yaml',
mutually_exclusive=('format_json',),
is_flag=True,
help=helptexts.SHOW_YAML,
mutuality_description='-j, --json',
mutuality_error=helptexts.FORMAT_MUTUALITY_ERROR_MESSAGE)
@staticmethod
def verbose(expose_value=False):
return click.option(
'-v',
'--verbose',
count=True,
callback=set_verbosity_level,
expose_value=expose_value,
is_eager=True,
help=helptexts.VERBOSE)
@staticmethod
def inputs(help):
return click.option(
'-i',
'--inputs',
multiple=True,
callback=inputs_callback,
help=help)
@staticmethod
def force(help):
return click.option(
'-f',
'--force',
is_flag=True,
help=help)
@staticmethod
def task_max_attempts(default=defaults.TASK_MAX_ATTEMPTS):
return click.option(
'--task-max-attempts',
type=int,
default=default,
help=helptexts.TASK_MAX_ATTEMPTS.format(default))
@staticmethod
def sort_by(default='created_at'):
return click.option(
'--sort-by',
required=False,
default=default,
help=helptexts.SORT_BY)
@staticmethod
def task_retry_interval(default=defaults.TASK_RETRY_INTERVAL):
return click.option(
'--task-retry-interval',
type=int,
default=default,
help=helptexts.TASK_RETRY_INTERVAL.format(default))
@staticmethod
def service_id(required=False):
return click.option(
'-s',
'--service-id',
required=required,
help=helptexts.SERVICE_ID)
@staticmethod
def execution_id(required=False):
return click.option(
'-e',
'--execution-id',
required=required,
help=helptexts.EXECUTION_ID)
@staticmethod
def service_template_id(required=False):
return click.option(
'-t',
'--service-template-id',
required=required,
help=helptexts.SERVICE_TEMPLATE_ID)
@staticmethod
def service_template_path(required=False):
return click.option(
'-p',
'--service-template-path',
required=required,
type=click.Path(exists=True))
@staticmethod
def service_name(required=False):
return click.option(
'-s',
'--service-name',
required=required,
help=helptexts.SERVICE_ID)
@staticmethod
def service_template_name(required=False):
return click.option(
'-t',
'--service-template-name',
required=required,
help=helptexts.SERVICE_ID)
@staticmethod
def mark_pattern():
return click.option(
'-m',
'--mark-pattern',
help=helptexts.MARK_PATTERN,
type=str,
required=False
)
options = Options()