diff --git a/docs/logging.md b/docs/logging.md index 1ce1ccf..aea975e 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -12,6 +12,7 @@ Logging - By default, log messages Warning and above are shown to the user. - `--verbose` - This flag changes the logging level to Info and above. - `--debug` - This flag changes the logging level to Debug and above. +- `--only-show-errors` - This flag changes the logging level to Error only, suppressing Warning. * All log messages go to STDERR (not STDOUT) * Log to Error or Warning for user messages instead of using the `print()` function diff --git a/examples/exapp2 b/examples/exapp2 index 154d63d..d6f7615 100644 --- a/examples/exapp2 +++ b/examples/exapp2 @@ -28,9 +28,50 @@ helps['abc list'] = """ text: {cli_name} abc list """.format(cli_name=cli_name) +helps['abc first'] = """ + type: command + short-summary: List the first several letters in the alphabet. + examples: + - name: Show the list of abc + text: {cli_name} abc first --number 3 +""".format(cli_name=cli_name) + +helps['abc last'] = """ + type: command + short-summary: List the last several letters in the alphabet. + examples: + - name: Show the list of xyz + text: {cli_name} abc last --number 3 +""".format(cli_name=cli_name) -def a_test_command_handler(): - return [{'a': 1, 'b': 1234}, {'a': 3, 'b': 4}] +helps['ga'] = """ + type: group + short-summary: A general available command group +""" + +helps['pre'] = """ + type: group + short-summary: A preview command group +""" + +helps['exp'] = """ + type: group + short-summary: An experimental command group +""" + + +def abc_show_command_handler(): + """ + Show a JSON mapping of letters to their ASCII values + """ + import string + lower = {} + for ch in string.ascii_lowercase: + lower[ch] = ord(ch) + upper = {} + for ch in string.ascii_uppercase: + upper[ch] = ord(ch) + return {"lowercase": lower, "uppercase": upper} def abc_list_command_handler(): @@ -38,8 +79,59 @@ def abc_list_command_handler(): return list(string.ascii_lowercase) -def hello_command_handler(myarg=None, abc=None): - return ['hello', 'world', myarg, abc] +def abc_first_command_handler(number=5): + import string + return list(string.ascii_lowercase)[0:number] + + +def abc_last_command_handler(number=5): + import string + return list(string.ascii_lowercase)[-number:] + + +def range_command_handler(start=0, end=5): + """ + Get a list of natural numbers from start to end + :param start: the lower bound + :param end: the higher bound + :return: + """ + return list(range(int(start), int(end) + 1)) + + +def sample_json_handler(): + """ + Get a sample JSON string + """ + # https://docs.microsoft.com/en-us/rest/api/resources/subscriptions/list#examples + result = { + "id": "/subscriptions/291bba3f-e0a5-47bc-a099-3bdcb2a50a05", + "subscriptionId": "291bba3f-e0a5-47bc-a099-3bdcb2a50a05", + "tenantId": "31c75423-32d6-4322-88b7-c478bdde4858", + "displayName": "Example Subscription", + "state": "Enabled", + "subscriptionPolicies": { + "locationPlacementId": "Internal_2014-09-01", + "quotaId": "Internal_2014-09-01", + "spendingLimit": "Off" + }, + "authorizationSource": "RoleBased", + "managedByTenants": [ + { + "tenantId": "8f70baf1-1f6e-46a2-a1ff-238dac1ebfb7" + } + ] + } + return result + + +def hello_command_handler(greetings=None): + """ + Say "Hello World!" and my warm greetings + :param greetings: My warm greetings + """ + return ['Hello World!', greetings] + WELCOME_MESSAGE = r""" _____ _ _____ @@ -65,17 +157,28 @@ class MyCLIHelp(CLIHelp): class MyCommandsLoader(CLICommandsLoader): def load_command_table(self, args): - with CommandGroup(self, 'hello', '__main__#{}') as g: - g.command('world', 'hello_command_handler', confirmation=True) + with CommandGroup(self, '', '__main__#{}') as g: + g.command('hello', 'hello_command_handler', confirmation=True) + g.command('sample-json', 'sample_json_handler') with CommandGroup(self, 'abc', '__main__#{}') as g: - g.command('list', 'abc_list_command_handler') - g.command('show', 'a_test_command_handler') - g.command('get', 'a_test_command_handler', deprecate_info=g.deprecate(redirect='show', hide='0.1.0')) + g.command('list', 'abc_list_command_handler') + g.command('show', 'abc_show_command_handler') + g.command('get', 'abc_show_command_handler', deprecate_info=g.deprecate(redirect='show', hide='1.0.0')) + g.command('first', 'abc_first_command_handler', is_preview=True) + g.command('last', 'abc_last_command_handler', is_experimental=True) + with CommandGroup(self, 'ga', '__main__#{}') as g: + g.command('range', 'range_command_handler') + with CommandGroup(self, 'pre', '__main__#{}', is_preview=True) as g: + g.command('first', 'abc_first_command_handler', is_preview=True) + g.command('range', 'range_command_handler') + with CommandGroup(self, 'exp', '__main__#{}', is_experimental=True) as g: + g.command('range', 'range_command_handler') return super(MyCommandsLoader, self).load_command_table(args) def load_arguments(self, command): - with ArgumentsContext(self, 'hello world') as ac: - ac.argument('myarg', type=int, default=100) + with ArgumentsContext(self, 'ga range') as ac: + ac.argument('start', type=int, is_preview=True) + ac.argument('end', type=int, is_experimental=True) super(MyCommandsLoader, self).load_arguments(command) diff --git a/knack/arguments.py b/knack/arguments.py index aabd038..46d8f1d 100644 --- a/knack/arguments.py +++ b/knack/arguments.py @@ -8,6 +8,7 @@ from .deprecation import Deprecated from .preview import PreviewItem +from .experimental import ExperimentalItem from .log import get_logger from .util import CLIError @@ -43,7 +44,8 @@ def update(self, other=None, **kwargs): class CLICommandArgument(object): - NAMED_ARGUMENTS = ['options_list', 'validator', 'completer', 'arg_group', 'deprecate_info', 'preview_info'] + NAMED_ARGUMENTS = ['options_list', 'validator', 'completer', 'arg_group', 'deprecate_info', 'preview_info', + 'experimental_info'] def __init__(self, dest=None, argtype=None, **kwargs): """An argument that has a specific destination parameter. @@ -265,6 +267,7 @@ def _get_preview_arg_message(self): object_type = 'positional argument' preview_info = PreviewItem( + cli_ctx=self.command_loader.cli_ctx, target=target, object_type=object_type, message_func=_get_preview_arg_message @@ -273,6 +276,56 @@ def _get_preview_arg_message(self): kwargs['action'] = _handle_argument_preview(preview_info) return kwargs + def _handle_experimentals(self, argument_dest, **kwargs): + + if not kwargs.get('is_experimental', False): + return kwargs + + def _handle_argument_experimental(experimental_info): + + parent_class = self._get_parent_class(**kwargs) + + class ExperimentalArgumentAction(parent_class): + + def __call__(self, parser, namespace, values, option_string=None): + if not hasattr(namespace, '_argument_experimentals'): + setattr(namespace, '_argument_experimentals', [experimental_info]) + else: + namespace._argument_experimentals.append(experimental_info) # pylint: disable=protected-access + try: + super(ExperimentalArgumentAction, self).__call__(parser, namespace, values, option_string) + except NotImplementedError: + setattr(namespace, self.dest, values) + + return ExperimentalArgumentAction + + def _get_experimental_arg_message(self): + return "{} '{}' is experimental and not covered by customer support. " \ + "Please use with discretion.".format(self.object_type.capitalize(), self.target) + + options_list = kwargs.get('options_list', None) + object_type = 'argument' + + if options_list is None: + # convert argument dest + target = '--{}'.format(argument_dest.replace('_', '-')) + elif options_list: + target = sorted(options_list, key=len)[-1] + else: + # positional argument + target = kwargs.get('metavar', '<{}>'.format(argument_dest.upper())) + object_type = 'positional argument' + + experimental_info = ExperimentalItem( + self.command_loader.cli_ctx, + target=target, + object_type=object_type, + message_func=_get_experimental_arg_message + ) + kwargs['experimental_info'] = experimental_info + kwargs['action'] = _handle_argument_experimental(experimental_info) + return kwargs + # pylint: disable=inconsistent-return-statements def deprecate(self, **kwargs): @@ -304,8 +357,8 @@ def argument(self, argument_dest, arg_type=None, **kwargs): :param arg_type: Predefined CLIArgumentType definition to register, as modified by any provided kwargs. :type arg_type: knack.arguments.CLIArgumentType :param kwargs: Possible values: `options_list`, `validator`, `completer`, `nargs`, `action`, `const`, `default`, - `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `deprecate_info`. - See /docs/arguments.md. + `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `is_experimental`, + `deprecate_info`. See /docs/arguments.md. """ self._check_stale() if not self._applicable(): @@ -315,7 +368,16 @@ def argument(self, argument_dest, arg_type=None, **kwargs): if deprecate_action: kwargs['action'] = deprecate_action + is_preview = kwargs.get('is_preview', False) + is_experimental = kwargs.get('is_experimental', False) + + if is_preview and is_experimental: + from .commands import PREVIEW_EXPERIMENTAL_CONFLICT_ERROR + raise CLIError(PREVIEW_EXPERIMENTAL_CONFLICT_ERROR.format('argument', argument_dest)) + kwargs = self._handle_previews(argument_dest, **kwargs) + kwargs = self._handle_experimentals(argument_dest, **kwargs) + self.command_loader.argument_registry.register_cli_argument(self.command_scope, argument_dest, arg_type, @@ -329,8 +391,8 @@ def positional(self, argument_dest, arg_type=None, **kwargs): :param arg_type: Predefined CLIArgumentType definition to register, as modified by any provided kwargs. :type arg_type: knack.arguments.CLIArgumentType :param kwargs: Possible values: `validator`, `completer`, `nargs`, `action`, `const`, `default`, - `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `deprecate_info`. - See /docs/arguments.md. + `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `is_experimental`, + `deprecate_info`. See /docs/arguments.md. """ self._check_stale() if not self._applicable(): @@ -356,6 +418,7 @@ def positional(self, argument_dest, arg_type=None, **kwargs): kwargs['action'] = deprecate_action kwargs = self._handle_previews(argument_dest, **kwargs) + kwargs = self._handle_experimentals(argument_dest, **kwargs) self.command_loader.argument_registry.register_cli_argument(self.command_scope, argument_dest, @@ -382,8 +445,8 @@ def extra(self, argument_dest, **kwargs): :param argument_dest: The destination argument to add this argument type to :type argument_dest: str :param kwargs: Possible values: `options_list`, `validator`, `completer`, `nargs`, `action`, `const`, `default`, - `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `deprecate_info`. - See /docs/arguments.md. + `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `is_experimental`, + `deprecate_info`. See /docs/arguments.md. """ self._check_stale() if not self._applicable(): @@ -399,6 +462,7 @@ def extra(self, argument_dest, **kwargs): kwargs['action'] = deprecate_action kwargs = self._handle_previews(argument_dest, **kwargs) + kwargs = self._handle_experimentals(argument_dest, **kwargs) self.command_loader.extra_argument_registry[self.command_scope][argument_dest] = CLICommandArgument( argument_dest, **kwargs) diff --git a/knack/cli.py b/knack/cli.py index 7b54112..8a21474 100644 --- a/knack/cli.py +++ b/knack/cli.py @@ -91,6 +91,8 @@ def __init__(self, self.output = self.output_cls(cli_ctx=self) self.result = None self.query = query_cls(cli_ctx=self) + self.only_show_errors = self.config.get('core', 'only_show_errors', fallback=False) + self.enable_color = not self.config.get('core', 'no_color', fallback=False) @staticmethod def _should_show_version(args): @@ -187,6 +189,13 @@ def invoke(self, args, initial_invocation_data=None, out_file=None): raise TypeError('args should be a list or tuple.') exit_code = 0 try: + if self.enable_color: + import colorama + colorama.init() + if self.out_file == sys.__stdout__: + # point out_file to the new sys.stdout which is overwritten by colorama + self.out_file = sys.stdout + args = self.completion.get_completion_args() or args out_file = out_file or self.out_file @@ -218,6 +227,7 @@ def invoke(self, args, initial_invocation_data=None, out_file=None): exit_code = self.exception_handler(ex) self.result = CommandResultItem(None, error=ex) finally: - pass + if self.enable_color: + colorama.deinit() self.result.exit_code = exit_code return exit_code diff --git a/knack/commands.py b/knack/commands.py index 50584a8..1b8b452 100644 --- a/knack/commands.py +++ b/knack/commands.py @@ -11,6 +11,7 @@ from .deprecation import Deprecated from .preview import PreviewItem +from .experimental import ExperimentalItem from .prompting import prompt_y_n, NoTTYException from .util import CLIError, CtxTypeError from .arguments import ArgumentRegistry, CLICommandArgument @@ -23,13 +24,17 @@ logger = get_logger(__name__) +PREVIEW_EXPERIMENTAL_CONFLICT_ERROR = "Failed to register {} '{}', " \ + "is_preview and is_experimental can't be true at the same time" + + class CLICommand(object): # pylint:disable=too-many-instance-attributes # pylint: disable=unused-argument def __init__(self, cli_ctx, name, handler, description=None, table_transformer=None, arguments_loader=None, description_loader=None, formatter_class=None, deprecate_info=None, validator=None, confirmation=None, preview_info=None, - **kwargs): + experimental_info=None, **kwargs): """ The command object that goes into the command table. :param cli_ctx: CLI Context @@ -52,6 +57,8 @@ def __init__(self, cli_ctx, name, handler, description=None, table_transformer=N :type deprecate_info: str :param preview_info: Indicates a command is in preview :type preview_info: bool + :param experimental_info: Indicates a command is experimental + :type experimental_info: bool :param validator: The command validator :param confirmation: User confirmation required for command :type confirmation: bool, str, callable @@ -71,6 +78,7 @@ def __init__(self, cli_ctx, name, handler, description=None, table_transformer=N self.formatter_class = formatter_class self.deprecate_info = deprecate_info self.preview_info = preview_info + self.experimental_info = experimental_info self.confirmation = confirmation self.validator = validator @@ -300,8 +308,20 @@ def __init__(self, command_loader, group_name, operations_tmpl, **kwargs): Deprecated.ensure_new_style_deprecation(self.command_loader.cli_ctx, self.group_kwargs, 'command group') if kwargs['deprecate_info']: kwargs['deprecate_info'].target = group_name - if kwargs.get('is_preview', False): + + is_preview = kwargs.get('is_preview', False) + is_experimental = kwargs.get('is_experimental', False) + if is_preview and is_experimental: + raise CLIError(PREVIEW_EXPERIMENTAL_CONFLICT_ERROR.format("command group", group_name)) + if is_preview: kwargs['preview_info'] = PreviewItem( + cli_ctx=self.command_loader.cli_ctx, + target=group_name, + object_type='command group' + ) + if is_experimental: + kwargs['experimental_info'] = ExperimentalItem( + cli_ctx=self.command_loader.cli_ctx, target=group_name, object_type='command group' ) @@ -324,20 +344,29 @@ def command(self, name, handler_name, **kwargs): :param kwargs: Kwargs to apply to the command. Possible values: `client_factory`, `arguments_loader`, `description_loader`, `description`, `formatter_class`, `table_transformer`, `deprecate_info`, `validator`, `confirmation`, - `is_preview`. + `is_preview`, `is_experimental`. """ import copy command_name = '{} {}'.format(self.group_name, name) if self.group_name else name command_kwargs = copy.deepcopy(self.group_kwargs) command_kwargs.update(kwargs) - # don't inherit deprecation info from command group + + # don't inherit deprecation, preview and experimental info from command group + # https://github.com/Azure/azure-cli/blob/683b9709b67c4c9e8df92f9fbd53cbf83b6973d3/src/azure-cli-core/azure/cli/core/commands/__init__.py#L1155 command_kwargs['deprecate_info'] = kwargs.get('deprecate_info', None) - if kwargs.get('is_preview', False): - command_kwargs['preview_info'] = PreviewItem( - self.command_loader.cli_ctx, - object_type='command' - ) + + is_preview = kwargs.get('is_preview', False) + is_experimental = kwargs.get('is_experimental', False) + if is_preview and is_experimental: + raise CLIError(PREVIEW_EXPERIMENTAL_CONFLICT_ERROR.format("command", self.group_name + " " + name)) + + command_kwargs['preview_info'] = None + if is_preview: + command_kwargs['preview_info'] = PreviewItem(self.command_loader.cli_ctx, object_type='command') + command_kwargs['experimental_info'] = None + if is_experimental: + command_kwargs['experimental_info'] = ExperimentalItem(self.command_loader.cli_ctx, object_type='command') self.command_loader._populate_command_group_table_with_subgroups(' '.join(command_name.split()[:-1])) # pylint: disable=protected-access self.command_loader.command_table[command_name] = self.command_loader.create_command( diff --git a/knack/experimental.py b/knack/experimental.py new file mode 100644 index 0000000..3105c2f --- /dev/null +++ b/knack/experimental.py @@ -0,0 +1,78 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from .util import StatusTag + +_EXPERIMENTAL_TAG = '[Experimental]' +_experimental_kwarg = 'experimental_info' + + +def resolve_experimental_info(cli_ctx, name): + + def _get_command(name): + return cli_ctx.invocation.commands_loader.command_table[name] + + def _get_command_group(name): + return cli_ctx.invocation.commands_loader.command_group_table.get(name, None) + + experimental_info = None + try: + command = _get_command(name) + experimental_info = getattr(command, _experimental_kwarg, None) + except KeyError: + command_group = _get_command_group(name) + group_kwargs = getattr(command_group, 'group_kwargs', None) + if group_kwargs: + experimental_info = group_kwargs.get(_experimental_kwarg, None) + return experimental_info + + +# pylint: disable=too-many-instance-attributes +class ExperimentalItem(StatusTag): + + def __init__(self, cli_ctx, object_type='', target=None, tag_func=None, message_func=None, **kwargs): + """ Create a collection of experimental metadata. + + :param cli_ctx: The CLI context associated with the experimental item. + :type cli_ctx: knack.cli.CLI + :param object_type: A label describing the type of object in experimental. + :type: object_type: str + :param target: The name of the object in experimental. + :type target: str + :param tag_func: Callable which returns the desired unformatted tag string for the experimental item. + Omit to use the default. + :type tag_func: callable + :param message_func: Callable which returns the desired unformatted message string for the experimental item. + Omit to use the default. + :type message_func: callable + """ + + def _default_get_message(self): + return "This {} is experimental and not covered by customer support. " \ + "Please use with discretion.".format(self.object_type) + + super(ExperimentalItem, self).__init__( + cli_ctx=cli_ctx, + object_type=object_type, + target=target, + color='red', + tag_func=tag_func or (lambda _: _EXPERIMENTAL_TAG), + message_func=message_func or _default_get_message + ) + + +class ImplicitExperimentalItem(ExperimentalItem): + + def __init__(self, **kwargs): + + def get_implicit_experimental_message(self): + return "Command group '{}' is experimental and not covered by customer support. " \ + "Please use with discretion.".format(self.target) + + kwargs.update({ + 'tag_func': lambda _: '', + 'message_func': get_implicit_experimental_message + }) + super(ImplicitExperimentalItem, self).__init__(**kwargs) diff --git a/knack/help.py b/knack/help.py index 80ccd72..cfaca51 100644 --- a/knack/help.py +++ b/knack/help.py @@ -11,6 +11,7 @@ from .deprecation import ImplicitDeprecated, resolve_deprecate_info from .log import get_logger from .preview import ImplicitPreviewItem, resolve_preview_info +from .experimental import ImplicitExperimentalItem, resolve_experimental_info from .util import CtxTypeError from .help_files import _load_help_file @@ -121,7 +122,7 @@ def _load_help_file_from_string(text): except Exception: # pylint: disable=broad-except return text - def __init__(self, help_ctx, delimiters): + def __init__(self, help_ctx, delimiters): # pylint: disable=too-many-statements super(HelpFile, self).__init__() self.help_ctx = help_ctx self.delimiters = delimiters @@ -133,6 +134,7 @@ def __init__(self, help_ctx, delimiters): self.examples = [] self.deprecate_info = None self.preview_info = None + self.experimental_info = None direct_deprecate_info = resolve_deprecate_info(help_ctx.cli_ctx, delimiters) if direct_deprecate_info: @@ -173,6 +175,26 @@ def __init__(self, help_ctx, delimiters): preview_kwargs['object_type'] = 'command group' self.preview_info = ImplicitPreviewItem(**preview_kwargs) + # resolve experimental info + direct_experimental_info = resolve_experimental_info(help_ctx.cli_ctx, delimiters) + if direct_experimental_info: + self.experimental_info = direct_experimental_info + + # search for implicit experimental + path_comps = delimiters.split()[:-1] + implicit_experimental_info = None + while path_comps and not implicit_experimental_info: + implicit_experimental_info = resolve_experimental_info(help_ctx.cli_ctx, ' '.join(path_comps)) + del path_comps[-1] + + if implicit_experimental_info: + experimental_kwargs = implicit_experimental_info.__dict__.copy() + if delimiters in help_ctx.cli_ctx.invocation.commands_loader.command_table: + experimental_kwargs['object_type'] = 'command' + else: + experimental_kwargs['object_type'] = 'command group' + self.experimental_info = ImplicitExperimentalItem(**experimental_kwargs) + def load(self, options): description = getattr(options, 'description', None) try: @@ -258,6 +280,7 @@ def __init__(self, help_ctx, delimiters, parser): 'name_source': [action.metavar or action.dest], 'deprecate_info': getattr(action, 'deprecate_info', None), 'preview_info': getattr(action, 'preview_info', None), + 'experimental_info': getattr(action, 'experimental_info', None), 'description': action.help, 'choices': action.choices, 'required': False, @@ -295,7 +318,8 @@ def _add_parameter_help(self, param): param_kwargs.update({ 'name_source': normal_options, 'deprecate_info': getattr(param, 'deprecate_info', None), - 'preview_info': getattr(param, 'preview_info', None) + 'preview_info': getattr(param, 'preview_info', None), + 'experimental_info': getattr(param, 'experimental_info', None) }) self.parameters.append(HelpParameter(**param_kwargs)) @@ -319,7 +343,7 @@ def _load_from_data(self, data): class HelpParameter(HelpObject): # pylint: disable=too-many-instance-attributes def __init__(self, name_source, description, required, choices=None, - default=None, group_name=None, deprecate_info=None, preview_info=None): + default=None, group_name=None, deprecate_info=None, preview_info=None, experimental_info=None): super(HelpParameter, self).__init__() self.name_source = name_source self.name = ' '.join(sorted(name_source)) @@ -333,6 +357,7 @@ def __init__(self, name_source, description, required, choices=None, self.group_name = group_name self.deprecate_info = deprecate_info self.preview_info = preview_info + self.experimental_info = experimental_info def update_from_data(self, data): if self.name != data.get('name'): @@ -385,6 +410,8 @@ def _build_long_summary(item): lines.append(str(item.deprecate_info.message)) if item.preview_info: lines.append(str(item.preview_info.message)) + if item.experimental_info: + lines.append(str(item.experimental_info.message)) return '\n'.join(lines) indent += 1 @@ -403,14 +430,18 @@ def _build_tags_string(item): preview_info = getattr(item, 'preview_info', None) preview = preview_info.tag if preview_info else '' + experimental_info = getattr(item, 'experimental_info', None) + experimental = experimental_info.tag if experimental_info else '' + deprecate_info = getattr(item, 'deprecate_info', None) deprecated = deprecate_info.tag if deprecate_info else '' required = REQUIRED_TAG if getattr(item, 'required', None) else '' - tags = ' '.join([x for x in [str(deprecated), str(preview), required] if x]) + tags = ' '.join([x for x in [str(deprecated), str(preview), str(experimental), required] if x]) tags_len = sum([ len(deprecated), len(preview), + len(experimental), len(required), tags.count(' ') ]) @@ -513,14 +544,18 @@ def _build_tags_string(item): preview_info = getattr(item, 'preview_info', None) preview = preview_info.tag if preview_info else '' + experimental_info = getattr(item, 'experimental_info', None) + experimental = experimental_info.tag if experimental_info else '' + deprecate_info = getattr(item, 'deprecate_info', None) deprecated = deprecate_info.tag if deprecate_info else '' required = REQUIRED_TAG if getattr(item, 'required', None) else '' - tags = ' '.join([x for x in [str(deprecated), str(preview), required] if x]) + tags = ' '.join([x for x in [str(deprecated), str(preview), str(experimental), required] if x]) tags_len = sum([ len(deprecated), len(preview), + len(experimental), len(required), tags.count(' ') ]) @@ -600,6 +635,9 @@ def _build_long_summary(item): preview_info = getattr(item, 'preview_info', None) if preview_info: lines.append(str(item.preview_info.message)) + experimental_info = getattr(item, 'experimental_info', None) + if experimental_info: + lines.append(str(item.experimental_info.message)) return ' '.join(lines) group_registry = ArgumentGroupRegistry([p.group_name for p in help_file.parameters if p.group_name]) @@ -684,8 +722,6 @@ def show_welcome(self, parser): self.print_description_list(help_file.children) def show_help(self, cli_name, nouns, parser, is_group): - import colorama - colorama.init(autoreset=True) delimiters = ' '.join(nouns) help_file = self.command_help_cls(self, delimiters, parser) if not is_group \ else self.group_help_cls(self, delimiters, parser) diff --git a/knack/invocation.py b/knack/invocation.py index b217f90..ebc6b4b 100644 --- a/knack/invocation.py +++ b/knack/invocation.py @@ -11,6 +11,7 @@ from .deprecation import ImplicitDeprecated, resolve_deprecate_info from .preview import ImplicitPreviewItem, resolve_preview_info +from .experimental import ImplicitExperimentalItem, resolve_experimental_info from .util import CLIError, CtxTypeError, CommandResultItem, todict from .parser import CLICommandParser from .commands import CLICommandsLoader @@ -127,7 +128,6 @@ def execute(self, args): :return: The command result :rtype: knack.util.CommandResultItem """ - import colorama self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args) cmd_tbl = self.commands_loader.load_command_table(args) @@ -139,7 +139,7 @@ def execute(self, args): self.parser.load_command_table(self.commands_loader) self.cli_ctx.raise_event(EVENT_INVOKER_CMD_TBL_LOADED, parser=self.parser) - arg_check = [a for a in args if a not in ['--verbose', '--debug']] + arg_check = [a for a in args if a not in ['--verbose', '--debug', '--only-show-warnings']] if not arg_check: self.cli_ctx.completion.enable_autocomplete(self.parser) subparser = self.parser.subparsers[tuple()] @@ -170,6 +170,10 @@ def execute(self, args): if cmd.preview_info: previews.append(cmd.preview_info) + experimentals = getattr(parsed_args, '_argument_experimentals', []) + if cmd.experimental_info: + experimentals.append(cmd.experimental_info) + params = self._filter_params(parsed_args) # search for implicit deprecation @@ -198,12 +202,25 @@ def execute(self, args): preview_kwargs['object_type'] = 'command' previews.append(ImplicitPreviewItem(**preview_kwargs)) - colorama.init() - for d in deprecations: - print(d.message, file=sys.stderr) - for p in previews: - print(p.message, file=sys.stderr) - colorama.deinit() + # search for implicit experimental + path_comps = cmd.name.split()[:-1] + implicit_experimental_info = None + while path_comps and not implicit_experimental_info: + implicit_experimental_info = resolve_experimental_info(self.cli_ctx, ' '.join(path_comps)) + del path_comps[-1] + + if implicit_experimental_info: + experimental_kwargs = implicit_experimental_info.__dict__.copy() + experimental_kwargs['object_type'] = 'command' + experimentals.append(ImplicitExperimentalItem(**experimental_kwargs)) + + if not self.cli_ctx.only_show_errors: + for d in deprecations: + print(d.message, file=sys.stderr) + for p in previews: + print(p.message, file=sys.stderr) + for p in experimentals: + print(p.message, file=sys.stderr) cmd_result = parsed_args.func(params) cmd_result = todict(cmd_result) diff --git a/knack/log.py b/knack/log.py index 8f37300..5dbabf6 100644 --- a/knack/log.py +++ b/knack/log.py @@ -7,7 +7,7 @@ import logging from logging.handlers import RotatingFileHandler -from .util import CtxTypeError, ensure_dir +from .util import CtxTypeError, ensure_dir, CLIError from .events import EVENT_PARSER_GLOBAL_CREATE CLI_LOGGER_NAME = 'cli' @@ -54,24 +54,10 @@ def wrap_msg_with_color(msg): return cls.COLOR_MAP.get(level, None) - def _should_enable_color(self): - try: - # Color if tty stream available - if self.stream.isatty(): - return True - except AttributeError: - pass - return False - - def __init__(self, log_level_config, log_format): - import platform - import colorama - + def __init__(self, log_level_config, log_format, enable_color): logging.StreamHandler.__init__(self) self.setLevel(log_level_config) - if platform.system() == 'Windows': - self.stream = colorama.AnsiToWin32(self.stream).stream - self.enable_color = self._should_enable_color() + self.enable_color = enable_color self.setFormatter(logging.Formatter(log_format[self.enable_color])) def format(self, record): @@ -88,6 +74,7 @@ class CLILogging(object): DEBUG_FLAG = '--debug' VERBOSE_FLAG = '--verbose' + ONLY_SHOW_ERRORS_FLAG = '--only-show-errors' @staticmethod def on_global_arguments(_, **kwargs): @@ -97,6 +84,9 @@ def on_global_arguments(_, **kwargs): help='Increase logging verbosity. Use --debug for full debug logs.') arg_group.add_argument(CLILogging.DEBUG_FLAG, dest='_log_verbosity_debug', action='store_true', help='Increase logging verbosity to show all debug logs.') + arg_group.add_argument(CLILogging.ONLY_SHOW_ERRORS_FLAG, dest='_log_verbosity_only_show_errors', + action='store_true', + help='Only show errors, suppressing warnings.') def __init__(self, name, cli_ctx=None): """ @@ -123,8 +113,8 @@ def configure(self, args): :param args: The arguments from the command line :type args: list """ - verbose_level = self._determine_verbose_level(args) - log_level_config = self.console_log_configs[verbose_level] + log_level = self._determine_log_level(args) + log_level_config = self.console_log_configs[log_level] root_logger = logging.getLogger() cli_logger = logging.getLogger(CLI_LOGGER_NAME) # Set the levels of the loggers to lowest level. @@ -140,22 +130,31 @@ def configure(self, args): self._init_logfile_handlers(root_logger, cli_logger) get_logger(__name__).debug("File logging enabled - writing logs to '%s'.", self.log_dir) - def _determine_verbose_level(self, args): + def _determine_log_level(self, args): """ Get verbose level by reading the arguments. """ - verbose_level = 0 - for arg in args: - if arg == CLILogging.VERBOSE_FLAG: - verbose_level += 1 - elif arg == CLILogging.DEBUG_FLAG: - verbose_level += 2 - # Use max verbose level if too much verbosity specified. - return min(verbose_level, len(self.console_log_configs) - 1) + # arguments have higher precedence than config + if CLILogging.ONLY_SHOW_ERRORS_FLAG in args: + if CLILogging.DEBUG_FLAG in args or CLILogging.VERBOSE_FLAG in args: + raise CLIError("--only-show-errors can't be used together with --debug or --verbose") + self.cli_ctx.only_show_errors = True + return 1 + if CLILogging.DEBUG_FLAG in args: + self.cli_ctx.only_show_errors = False + return 4 + if CLILogging.VERBOSE_FLAG in args: + self.cli_ctx.only_show_errors = False + return 3 + if self.cli_ctx.only_show_errors: + return 1 + return 2 # default to show WARNINGs and above def _init_console_handlers(self, root_logger, cli_logger, log_level_config): root_logger.addHandler(_CustomStreamHandler(log_level_config['root'], - self.console_log_format['root'])) + self.console_log_format['root'], + self.cli_ctx.enable_color)) cli_logger.addHandler(_CustomStreamHandler(log_level_config[CLI_LOGGER_NAME], - self.console_log_format[CLI_LOGGER_NAME])) + self.console_log_format[CLI_LOGGER_NAME], + self.cli_ctx.enable_color)) def _init_logfile_handlers(self, root_logger, cli_logger): ensure_dir(self.log_dir) @@ -179,6 +178,16 @@ def _get_log_dir(cli_ctx): @staticmethod def _get_console_log_configs(): return [ + # --only-show-critical [RESERVED] + { + CLI_LOGGER_NAME: logging.CRITICAL, + 'root': logging.CRITICAL + }, + # --only-show-errors + { + CLI_LOGGER_NAME: logging.ERROR, + 'root': logging.CRITICAL + }, # (default) { CLI_LOGGER_NAME: logging.WARNING, diff --git a/knack/output.py b/knack/output.py index bbe3277..4463ef0 100644 --- a/knack/output.py +++ b/knack/output.py @@ -146,11 +146,6 @@ def out(self, obj, formatter=None, out_file=None): # pylint: disable=no-self-us if not isinstance(obj, CommandResultItem): raise TypeError('Expected {} got {}'.format(CommandResultItem.__name__, type(obj))) - import platform - import colorama - - if platform.system() == 'Windows': - out_file = colorama.AnsiToWin32(out_file).stream output = formatter(obj) try: print(output, file=out_file, end='') diff --git a/knack/parser.py b/knack/parser.py index 02b8180..b4ded2c 100644 --- a/knack/parser.py +++ b/knack/parser.py @@ -173,6 +173,7 @@ def load_command_table(self, command_loader): param.completer = arg.completer param.deprecate_info = arg.deprecate_info param.preview_info = arg.preview_info + param.experimental_info = arg.experimental_info command_parser.set_defaults( func=metadata, command=command_name, diff --git a/knack/preview.py b/knack/preview.py index 23f97f7..3aa441a 100644 --- a/knack/preview.py +++ b/knack/preview.py @@ -32,7 +32,7 @@ def _get_command_group(name): # pylint: disable=too-many-instance-attributes class PreviewItem(StatusTag): - def __init__(self, cli_ctx=None, object_type='', target=None, tag_func=None, message_func=None, **kwargs): + def __init__(self, cli_ctx, object_type='', target=None, tag_func=None, message_func=None, **kwargs): """ Create a collection of preview metadata. :param cli_ctx: The CLI context associated with the preview item. diff --git a/knack/util.py b/knack/util.py index 8988dd5..ae78ab1 100644 --- a/knack/util.py +++ b/knack/util.py @@ -9,6 +9,8 @@ from datetime import date, time, datetime, timedelta from enum import Enum +NO_COLOR_VARIABLE_NAME = 'KNACK_NO_COLOR' + class CommandResultItem(object): # pylint: disable=too-few-public-methods def __init__(self, result, table_transformer=None, is_query_active=False, @@ -90,12 +92,13 @@ def show_in_help(self): @property def tag(self): """ Returns a tag object. """ - return ColorizedString(self._get_tag(self), self._color) + return ColorizedString(self._get_tag(self), self._color) if self.cli_ctx.enable_color else self._get_tag(self) @property def message(self): """ Returns a tuple with the formatted message string and the message length. """ - return ColorizedString(self._get_message(self), self._color) + return ColorizedString(self._get_message(self), self._color) if self.cli_ctx.enable_color \ + else "WARNING: " + self._get_message(self) def ensure_dir(d): diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py index 323f956..66d4ff4 100644 --- a/tests/test_deprecation.py +++ b/tests/test_deprecation.py @@ -15,7 +15,7 @@ from knack.arguments import ArgumentsContext from knack.commands import CLICommand, CLICommandsLoader, CommandGroup -from tests.util import DummyCLI, redirect_io +from tests.util import DummyCLI, redirect_io, disable_color def example_handler(arg1, arg2=None, arg3=None): @@ -98,8 +98,8 @@ def test_deprecate_command_help_hidden(self): cmd3' instead. Arguments - -b [Required] : Allowed values: a, b, c. - --arg -a : Allowed values: 1, 2, 3. + -b [Required] : Allowed values: a, b, c. + --arg -a : Allowed values: 1, 2, 3. --arg3 """.format(self.cli_ctx.name) self.assertIn(expected, actual) @@ -128,13 +128,32 @@ def test_deprecate_command_expiring_execute(self): expected = "This command has been deprecated and will be removed in version '1.0.0'. Use 'alt-cmd4' instead." self.assertIn(expected, actual) + @redirect_io + def test_deprecate_command_expiring_execute_no_color(self): + """ Ensure warning is displayed without color. """ + self.cli_ctx.enable_color = False + self.cli_ctx.invoke('cmd4 -b b'.split()) + actual = self.io.getvalue() + expected = "WARNING: This command has been deprecated and will be removed in version '1.0.0'" + self.assertIn(expected, actual) + @redirect_io def test_deprecate_command_expired_execute(self): """ Ensure expired command cannot be reached. """ with self.assertRaises(SystemExit): self.cli_ctx.invoke('cmd5 -h'.split()) actual = self.io.getvalue() - expected = """The most similar choices to 'cmd5'""" + expected = """cli: 'cmd5' is not in the 'cli' command group.""" + self.assertIn(expected, actual) + + @redirect_io + @disable_color + def test_deprecate_command_expired_execute_no_color(self): + """ Ensure error is displayed without color. """ + with self.assertRaises(SystemExit): + self.cli_ctx.invoke('cmd5 -h'.split()) + actual = self.io.getvalue() + expected = """ERROR: cli: 'cmd5' is not in the 'cli' command group.""" self.assertIn(expected, actual) @@ -228,6 +247,21 @@ def test_deprecate_command_group_help_expiring(self): """.format(self.cli_ctx.name) self.assertIn(expected, actual) + @redirect_io + @disable_color + def test_deprecate_command_group_help_expiring_no_color(self): + """ Ensure warning is displayed without color. """ + with self.assertRaises(SystemExit): + self.cli_ctx.invoke('group4 -h'.split()) + actual = self.io.getvalue() + expected = """ +Group + cli group4 + WARNING: This command group has been deprecated and will be removed in version \'1.0.0\'. Use + 'alt-group4' instead. +""".format(self.cli_ctx.name) + self.assertIn(expected, actual) + @redirect_io def test_deprecate_command_group_expired(self): """ Ensure expired command cannot be reached. """ @@ -411,6 +445,15 @@ def test_deprecate_options_execute_expiring(self): expected = "Option '--alt4' has been deprecated and will be removed in version '1.0.0'. Use '--opt4' instead." self.assertIn(expected, actual) + @redirect_io + @disable_color + def test_deprecate_options_execute_expiring_no_color(self): + """ Ensure error is displayed without color. """ + self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --alt4 bar'.split()) + actual = self.io.getvalue() + expected = "WARNING: Option '--alt4' has been deprecated and will be removed in version '1.0.0'. Use '--opt4' instead." + self.assertIn(expected, actual) + @redirect_io def test_deprecate_options_execute_expiring_non_deprecated(self): """ Ensure non-expiring options can be used without warning. """ diff --git a/tests/test_experimental.py b/tests/test_experimental.py new file mode 100644 index 0000000..e519ee0 --- /dev/null +++ b/tests/test_experimental.py @@ -0,0 +1,215 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import unicode_literals, print_function + +import unittest +try: + import mock +except ImportError: + from unittest import mock + +import sys +import argparse + +from knack.arguments import ArgumentsContext +from knack.commands import CLICommandsLoader, CommandGroup + +from tests.util import DummyCLI, redirect_io, remove_space + + +def example_handler(arg1, arg2=None, arg3=None): + """ Short summary here. Long summary here. Still long summary. """ + pass + + +def example_arg_handler(arg1, opt1, arg2=None, opt2=None, arg3=None, + opt3=None, arg4=None, opt4=None, arg5=None, opt5=None): + pass + + +class TestCommandExperimental(unittest.TestCase): + + def setUp(self): + + from knack.help_files import helps + + class ExperimentalTestCommandLoader(CLICommandsLoader): + def load_command_table(self, args): + super(ExperimentalTestCommandLoader, self).load_command_table(args) + with CommandGroup(self, '', '{}#{{}}'.format(__name__)) as g: + g.command('cmd1', 'example_handler', is_experimental=True) + + with CommandGroup(self, 'grp1', '{}#{{}}'.format(__name__), is_experimental=True) as g: + g.command('cmd1', 'example_handler') + + return self.command_table + + def load_arguments(self, command): + with ArgumentsContext(self, '') as c: + c.argument('arg1', options_list=['--arg', '-a'], required=False, type=int, choices=[1, 2, 3]) + c.argument('arg2', options_list=['-b'], required=True, choices=['a', 'b', 'c']) + + super(ExperimentalTestCommandLoader, self).load_arguments(command) + + helps['grp1'] = """ + type: group + short-summary: A group. +""" + self.cli_ctx = DummyCLI(commands_loader_cls=ExperimentalTestCommandLoader) + + @redirect_io + def test_experimental_command_implicitly_execute(self): + """ Ensure general warning displayed when running command from an experimental parent group. """ + self.cli_ctx.invoke('grp1 cmd1 -b b'.split()) + actual = self.io.getvalue() + expected = "Command group 'grp1' is experimental and not covered by customer support. " \ + "Please use with discretion." + self.assertIn(remove_space(expected), remove_space(actual)) + + @redirect_io + def test_experimental_command_group_help(self): + """ Ensure experimental commands appear correctly in group help view. """ + with self.assertRaises(SystemExit): + self.cli_ctx.invoke('-h'.split()) + actual = self.io.getvalue() + expected = u""" +Group + {} + +Subgroups: + grp1 [Experimental] : A group. + +Commands: + cmd1 [Experimental] : Short summary here. + +""".format(self.cli_ctx.name) + self.assertEqual(expected, actual) + + @redirect_io + def test_experimental_command_plain_execute(self): + """ Ensure general warning displayed when running experimental command. """ + self.cli_ctx.invoke('cmd1 -b b'.split()) + actual = self.io.getvalue() + expected = "This command is experimental and not covered by customer support. Please use with discretion." + self.assertIn(remove_space(expected), remove_space(actual)) + + +class TestCommandGroupExperimental(unittest.TestCase): + + def setUp(self): + + from knack.help_files import helps + + class ExperimentalTestCommandLoader(CLICommandsLoader): + def load_command_table(self, args): + super(ExperimentalTestCommandLoader, self).load_command_table(args) + + with CommandGroup(self, 'group1', '{}#{{}}'.format(__name__), is_experimental=True) as g: + g.command('cmd1', 'example_handler') + + return self.command_table + + def load_arguments(self, command): + with ArgumentsContext(self, '') as c: + c.argument('arg1', options_list=['--arg', '-a'], required=False, type=int, choices=[1, 2, 3]) + c.argument('arg2', options_list=['-b'], required=True, choices=['a', 'b', 'c']) + + super(ExperimentalTestCommandLoader, self).load_arguments(command) + + helps['group1'] = """ + type: group + short-summary: A group. +""" + self.cli_ctx = DummyCLI(commands_loader_cls=ExperimentalTestCommandLoader) + + @redirect_io + def test_experimental_command_group_help_plain(self): + """ Ensure help warnings appear for experimental command group help. """ + with self.assertRaises(SystemExit): + self.cli_ctx.invoke('group1 -h'.split()) + actual = self.io.getvalue() + expected = """ +Group + cli group1 : A group. + This command group is experimental and not covered by customer support. Please use with discretion. +Commands: + cmd1 : Short summary here. + +""".format(self.cli_ctx.name) + self.assertIn(remove_space(expected), remove_space(actual)) + + @redirect_io + def test_experimental_command_implicitly(self): + """ Ensure help warning displayed for command in experimental because of a experimental parent group. """ + with self.assertRaises(SystemExit): + self.cli_ctx.invoke('group1 cmd1 -h'.split()) + actual = self.io.getvalue() + expected = """ +Command + {} group1 cmd1 : Short summary here. + Long summary here. Still long summary. + Command group 'group1' is experimental and not covered by customer support. Please use with discretion. +""".format(self.cli_ctx.name) + self.assertIn(remove_space(expected), remove_space(actual)) + + +class TestArgumentExperimental(unittest.TestCase): + + def setUp(self): + from knack.help_files import helps + + class LoggerAction(argparse.Action): + + def __call__(self, parser, namespace, values, option_string=None): + print("Side-effect from some original action!", file=sys.stderr) + + class ExperimentalTestCommandLoader(CLICommandsLoader): + def load_command_table(self, args): + super(ExperimentalTestCommandLoader, self).load_command_table(args) + with CommandGroup(self, '', '{}#{{}}'.format(__name__)) as g: + g.command('arg-test', 'example_arg_handler') + return self.command_table + + def load_arguments(self, command): + with ArgumentsContext(self, 'arg-test') as c: + c.argument('arg1', help='Arg1', is_experimental=True, action=LoggerAction) + + super(ExperimentalTestCommandLoader, self).load_arguments(command) + + helps['grp1'] = """ + type: group + short-summary: A group. +""" + self.cli_ctx = DummyCLI(commands_loader_cls=ExperimentalTestCommandLoader) + + @redirect_io + def test_experimental_arguments_command_help(self): + """ Ensure experimental arguments appear correctly in command help view. """ + with self.assertRaises(SystemExit): + self.cli_ctx.invoke('arg-test -h'.split()) + actual = self.io.getvalue() + expected = """ +Arguments + --arg1 [Experimental] [Required] : Arg1. + Argument '--arg1' is experimental and not covered by customer support. Please use with discretion. +""".format(self.cli_ctx.name) + self.assertIn(remove_space(expected), remove_space(actual)) + + @redirect_io + def test_experimental_arguments_execute(self): + """ Ensure deprecated arguments can be used. """ + self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar'.split()) + actual = self.io.getvalue() + experimental_expected = "Argument '--arg1' is experimental and not covered by customer support. " \ + "Please use with discretion." + self.assertIn(experimental_expected, actual) + + action_expected = "Side-effect from some original action!" + self.assertIn(action_expected, actual) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_help.py b/tests/test_help.py index e1ca100..e05c6b0 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -295,6 +295,7 @@ def test_help_full_documentations(self): Global Arguments --debug : Increase logging verbosity to show all debug logs. --help -h : Show this help message and exit. + --only-show-errors : Only show errors, suppressing warnings. --output -o : Output format. Allowed values: json, jsonc, none, table, tsv, yaml, yamlc. Default: json. --query : JMESPath query string. See http://jmespath.org/ for more information and @@ -322,18 +323,19 @@ def test_help_with_param_specified(self): Long summary here. Still long summary. Arguments - -b [Required] : Allowed values: a, b, c. - --arg -a : Allowed values: 1, 2, 3. + -b [Required] : Allowed values: a, b, c. + --arg -a : Allowed values: 1, 2, 3. --arg3 Global Arguments - --debug : Increase logging verbosity to show all debug logs. - --help -h : Show this help message and exit. - --output -o : Output format. Allowed values: json, jsonc, none, table, tsv, yaml, yamlc. - Default: json. - --query : JMESPath query string. See http://jmespath.org/ for more information and - examples. - --verbose : Increase logging verbosity. Use --debug for full debug logs. + --debug : Increase logging verbosity to show all debug logs. + --help -h : Show this help message and exit. + --only-show-errors : Only show errors, suppressing warnings. + --output -o : Output format. Allowed values: json, jsonc, none, table, tsv, yaml, yamlc. + Default: json. + --query : JMESPath query string. See http://jmespath.org/ for more information and + examples. + --verbose : Increase logging verbosity. Use --debug for full debug logs. """ actual = io.getvalue() @@ -437,19 +439,20 @@ def register_globals(_, **kwargs): Long summary here. Still long summary. Arguments - -b [Required] : Allowed values: a, b, c. - --arg -a : Allowed values: 1, 2, 3. + -b [Required] : Allowed values: a, b, c. + --arg -a : Allowed values: 1, 2, 3. --arg3 Global Arguments - --debug : Increase logging verbosity to show all debug logs. - --exampl : This is a new global argument. - --help -h : Show this help message and exit. - --output -o : Output format. Allowed values: json, jsonc, none, table, tsv, yaml, yamlc. - Default: json. - --query : JMESPath query string. See http://jmespath.org/ for more information and - examples. - --verbose : Increase logging verbosity. Use --debug for full debug logs. + --debug : Increase logging verbosity to show all debug logs. + --exampl : This is a new global argument. + --help -h : Show this help message and exit. + --only-show-errors : Only show errors, suppressing warnings. + --output -o : Output format. Allowed values: json, jsonc, none, table, tsv, yaml, yamlc. + Default: json. + --query : JMESPath query string. See http://jmespath.org/ for more information and + examples. + --verbose : Increase logging verbosity. Use --debug for full debug logs. """ actual = io.getvalue() diff --git a/tests/test_log.py b/tests/test_log.py index 06f0c1e..5791904 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -13,6 +13,7 @@ from knack.events import EVENT_PARSER_GLOBAL_CREATE, EVENT_INVOKER_PRE_CMD_TBL_CREATE from knack.log import CLILogging, get_logger, CLI_LOGGER_NAME, _CustomStreamHandler +from knack.util import CLIError from tests.util import MockContext @@ -39,40 +40,59 @@ def setUp(self): self.mock_ctx = MockContext() self.cli_logging = CLILogging('clitest', cli_ctx=self.mock_ctx) - def test_determine_verbose_level_default(self): + def test_determine_log_level_default(self): argv = [] - actual_level = self.cli_logging._determine_verbose_level(argv) # pylint: disable=protected-access - expected_level = 0 + actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access + expected_level = 2 self.assertEqual(actual_level, expected_level) - def test_determine_verbose_level_verbose(self): + def test_determine_log_level_verbose(self): argv = ['--verbose'] - actual_level = self.cli_logging._determine_verbose_level(argv) # pylint: disable=protected-access - expected_level = 1 + actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access + expected_level = 3 self.assertEqual(actual_level, expected_level) - def test_determine_verbose_level_debug(self): + def test_determine_log_level_debug(self): argv = ['--debug'] - actual_level = self.cli_logging._determine_verbose_level(argv) # pylint: disable=protected-access - expected_level = 2 + actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access + expected_level = 4 self.assertEqual(actual_level, expected_level) - def test_determine_verbose_level_v_v_v_default(self): + def test_determine_log_level_v_v_v_default(self): argv = ['--verbose', '--debug'] - actual_level = self.cli_logging._determine_verbose_level(argv) # pylint: disable=protected-access - expected_level = 2 + actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access + expected_level = 4 self.assertEqual(actual_level, expected_level) - def test_determine_verbose_level_other_args_verbose(self): - argv = ['account', '--verbose'] - actual_level = self.cli_logging._determine_verbose_level(argv) # pylint: disable=protected-access + def test_determine_log_level_only_show_errors(self): + argv = ['--only-show-errors'] + actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access + expected_level = 1 + self.assertEqual(actual_level, expected_level) + + def test_determine_log_level_only_show_errors_config(self): + argv = [] + self.mock_ctx.only_show_errors = True + actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access expected_level = 1 self.assertEqual(actual_level, expected_level) + self.mock_ctx.only_show_errors = False - def test_determine_verbose_level_other_args_debug(self): + def test_determine_log_level_all_flags(self): + argv = ['--verbose', '--debug', '--only-show-errors'] + with self.assertRaises(CLIError): + self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access + + def test_determine_log_level_other_args_verbose(self): + argv = ['account', '--verbose'] + actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access + expected_level = 3 + self.assertEqual(actual_level, expected_level) + + def test_determine_log_level_other_args_debug(self): argv = ['account', '--debug'] - actual_level = self.cli_logging._determine_verbose_level(argv) # pylint: disable=protected-access - expected_level = 2 + actual_level = self.cli_logging._determine_log_level(argv) # pylint: disable=protected-access + expected_level = 4 self.assertEqual(actual_level, expected_level) def test_get_cli_logger(self): diff --git a/tests/test_preview.py b/tests/test_preview.py index f6e7084..868dab5 100644 --- a/tests/test_preview.py +++ b/tests/test_preview.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals, print_function +import os import unittest try: import mock @@ -17,7 +18,7 @@ from knack.arguments import ArgumentsContext from knack.commands import CLICommandsLoader, CommandGroup -from tests.util import DummyCLI, redirect_io +from tests.util import DummyCLI, redirect_io, disable_color def example_handler(arg1, arg2=None, arg3=None): @@ -87,6 +88,49 @@ def test_preview_command_plain_execute(self): expected = "This command is in preview. It may be changed/removed in a future release." self.assertIn(expected, actual) + @redirect_io + def test_preview_command_plain_execute_only_show_error(self): + """ Ensure warning is suppressed when running preview command. """ + # Directly use --only-show-errors + self.cli_ctx.invoke('cmd1 -b b --only-show-errors'.split()) + actual = self.io.getvalue() + self.assertNotIn("preview", actual) + + # Apply --only-show-errors with config + self.cli_ctx.only_show_errors = True + self.cli_ctx.config.set_value('core', 'only_show_errors', 'True') + self.cli_ctx.invoke('cmd1 -b b'.split()) + actual = self.io.getvalue() + self.assertNotIn("preview", actual) + self.cli_ctx.config.set_value('core', 'only_show_errors', '') + self.cli_ctx.only_show_errors = False + + + @redirect_io + @disable_color + def test_preview_command_plain_execute_no_color(self): + """ Ensure warning is displayed without color. """ + self.cli_ctx.invoke('cmd1 -b b'.split()) + actual = self.io.getvalue() + self.assertIn("WARNING: This command is in preview. It may be changed/removed in a future release.", actual) + + @redirect_io + def test_preview_command_implicitly_execute(self): + """ Ensure general warning displayed when running command from a preview parent group. """ + self.cli_ctx.invoke('grp1 cmd1 -b b'.split()) + actual = self.io.getvalue() + expected = "Command group 'grp1' is in preview. It may be changed/removed in a future release." + self.assertIn(expected, actual) + + @redirect_io + @disable_color + def test_preview_command_implicitly_no_color(self): + """ Ensure warning is displayed without color. """ + self.cli_ctx.invoke('grp1 cmd1 -b b'.split()) + actual = self.io.getvalue() + expected = "WARNING: Command group 'grp1' is in preview. It may be changed/removed in a future release." + self.assertIn(expected, actual) + class TestCommandGroupPreview(unittest.TestCase): @@ -129,6 +173,23 @@ def test_preview_command_group_help_plain(self): Commands: cmd1 : Short summary here. +""".format(self.cli_ctx.name) + self.assertEqual(expected, actual) + + @redirect_io + @disable_color + def test_preview_command_group_help_plain_no_color(self): + """ Ensure warning is displayed without color. """ + with self.assertRaises(SystemExit): + self.cli_ctx.invoke('group1 -h'.split()) + actual = self.io.getvalue() + expected = """ +Group + cli group1 : A group. + WARNING: This command group is in preview. It may be changed/removed in a future release. +Commands: + cmd1 : Short summary here. + """.format(self.cli_ctx.name) self.assertEqual(expected, actual) @@ -190,6 +251,20 @@ def test_preview_arguments_command_help(self): """.format(self.cli_ctx.name) self.assertIn(expected, actual) + @redirect_io + @disable_color + def test_preview_arguments_command_help_no_color(self): + """ Ensure warning is displayed without color. """ + with self.assertRaises(SystemExit): + self.cli_ctx.invoke('arg-test -h'.split()) + actual = self.io.getvalue() + expected = """ +Arguments + --arg1 [Preview] [Required] : Arg1. + WARNING: Argument '--arg1' is in preview. It may be changed/removed in a future release. +""".format(self.cli_ctx.name) + self.assertIn(expected, actual) + @redirect_io def test_preview_arguments_execute(self): """ Ensure deprecated arguments can be used. """ @@ -201,6 +276,28 @@ def test_preview_arguments_execute(self): action_expected = "Side-effect from some original action!" self.assertIn(action_expected, actual) + @redirect_io + @disable_color + def test_preview_arguments_execute_no_color(self): + """ Ensure warning is displayed without color. """ + self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar'.split()) + actual = self.io.getvalue() + preview_expected = "WARNING: Argument '--arg1' is in preview. It may be changed/removed in a future release." + self.assertIn(preview_expected, actual) + + action_expected = "Side-effect from some original action!" + self.assertIn(action_expected, actual) + + + @redirect_io + def test_preview_arguments_execute_only_show_error(self): + """ Ensure warning is suppressed when using preview arguments. """ + self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar --only-show-errors'.split()) + actual = self.io.getvalue() + self.assertNotIn("preview", actual) + + action_expected = "Side-effect from some original action!" + self.assertIn(action_expected, actual) if __name__ == '__main__': unittest.main() diff --git a/tests/util.py b/tests/util.py index 57f8c7c..8768ef4 100644 --- a/tests/util.py +++ b/tests/util.py @@ -12,6 +12,8 @@ import shutil import os from six import StringIO +import logging +from knack.log import CLI_LOGGER_NAME from knack.cli import CLI, CLICommandsLoader, CommandInvoker @@ -29,9 +31,28 @@ def wrapper(self): self.io.close() sys.stdout = original_stderr sys.stderr = original_stderr + + # Remove the handlers added by CLI, so that the next invoke call init them again with the new stderr + # Otherwise, the handlers will write to a closed StringIO from a preview test + root_logger = logging.getLogger() + cli_logger = logging.getLogger(CLI_LOGGER_NAME) + root_logger.handlers = root_logger.handlers[:-1] + cli_logger.handlers = cli_logger.handlers[:-1] + return wrapper + + +def disable_color(func): + def wrapper(self): + self.cli_ctx.enable_color = False + func(self) + self.cli_ctx.enable_color = True return wrapper +def remove_space(str): + return str.replace(' ', '').replace('\n', '') + + class MockContext(CLI): def __init__(self):