From 7c65fbfa4a70a63fead428d144cfbe2ba1fb201e Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Wed, 11 Mar 2020 16:55:19 +0800 Subject: [PATCH 1/3] add experimental tag --- examples/exapp2 | 123 ++++++++++++++++++++-- knack/arguments.py | 69 +++++++++++-- knack/commands.py | 28 +++-- knack/experimental.py | 78 ++++++++++++++ knack/help.py | 48 ++++++++- knack/invocation.py | 19 ++++ knack/parser.py | 1 + tests/test_experimental.py | 206 +++++++++++++++++++++++++++++++++++++ tests/util.py | 4 + 9 files changed, 548 insertions(+), 28 deletions(-) create mode 100644 knack/experimental.py create mode 100644 tests/test_experimental.py diff --git a/examples/exapp2 b/examples/exapp2 index 154d63d..7d77604 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,30 @@ 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..e5fd84a 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. @@ -273,6 +275,55 @@ 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( + 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 +355,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(): @@ -316,6 +367,8 @@ def argument(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, arg_type, @@ -329,8 +382,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 +409,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 +436,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 +453,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/commands.py b/knack/commands.py index 50584a8..d7c3100 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 @@ -29,7 +30,7 @@ class CLICommand(object): # pylint:disable=too-many-instance-attributes 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 +53,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 +74,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 @@ -305,6 +309,11 @@ def __init__(self, command_loader, group_name, operations_tmpl, **kwargs): target=group_name, object_type='command group' ) + if kwargs.get('is_experimental', False): + kwargs['experimental_info'] = ExperimentalItem( + target=group_name, + object_type='command group' + ) command_loader._populate_command_group_table_with_subgroups(group_name) # pylint: disable=protected-access self.command_loader.command_group_table[group_name] = self @@ -324,20 +333,25 @@ 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) + + command_kwargs['preview_info'] = None if kwargs.get('is_preview', False): - command_kwargs['preview_info'] = PreviewItem( - self.command_loader.cli_ctx, - object_type='command' - ) + command_kwargs['preview_info'] = PreviewItem(self.command_loader.cli_ctx, object_type='command') + + command_kwargs['experimental_info'] = None + if kwargs.get('is_experimental', False): + 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..43f7afb --- /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=None, 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..b254d1a 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]) diff --git a/knack/invocation.py b/knack/invocation.py index b217f90..31a6dbe 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 @@ -170,6 +171,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,11 +203,25 @@ def execute(self, args): preview_kwargs['object_type'] = 'command' previews.append(ImplicitPreviewItem(**preview_kwargs)) + # 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)) + colorama.init() 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) colorama.deinit() cmd_result = parsed_args.func(params) 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/tests/test_experimental.py b/tests/test_experimental.py new file mode 100644 index 0000000..8b2da52 --- /dev/null +++ b/tests/test_experimental.py @@ -0,0 +1,206 @@ +# -------------------------------------------------------------------------------------------- +# 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_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/util.py b/tests/util.py index 57f8c7c..dc396b0 100644 --- a/tests/util.py +++ b/tests/util.py @@ -32,6 +32,10 @@ def wrapper(self): return wrapper +def remove_space(str): + return str.replace(' ', '').replace('\n', '') + + class MockContext(CLI): def __init__(self): From 9e1637f049fc4cc5026f35a55fdc8c85389b027e Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Fri, 13 Mar 2020 21:23:40 +0800 Subject: [PATCH 2/3] is_preview and is_experimental can't be true at the same time --- knack/arguments.py | 7 +++++++ knack/commands.py | 23 ++++++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/knack/arguments.py b/knack/arguments.py index e5fd84a..d1e8ad6 100644 --- a/knack/arguments.py +++ b/knack/arguments.py @@ -366,6 +366,13 @@ 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) diff --git a/knack/commands.py b/knack/commands.py index d7c3100..a1398ba 100644 --- a/knack/commands.py +++ b/knack/commands.py @@ -24,6 +24,10 @@ 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 @@ -304,12 +308,17 @@ 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( target=group_name, object_type='command group' ) - if kwargs.get('is_experimental', False): + if is_experimental: kwargs['experimental_info'] = ExperimentalItem( target=group_name, object_type='command group' @@ -345,12 +354,16 @@ def command(self, name, handler_name, **kwargs): # 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) + 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 kwargs.get('is_preview', False): + if is_preview: command_kwargs['preview_info'] = PreviewItem(self.command_loader.cli_ctx, object_type='command') - command_kwargs['experimental_info'] = None - if kwargs.get('is_experimental', False): + 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 From 81357dbaf4da9722217b27970d3f714c95acbc7a Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Mon, 16 Mar 2020 20:44:00 +0800 Subject: [PATCH 3/3] refine --- examples/exapp2 | 2 -- knack/arguments.py | 1 + knack/commands.py | 1 + knack/experimental.py | 2 +- tests/test_experimental.py | 9 +++++++++ 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/examples/exapp2 b/examples/exapp2 index 7d77604..d6f7615 100644 --- a/examples/exapp2 +++ b/examples/exapp2 @@ -176,8 +176,6 @@ class MyCommandsLoader(CLICommandsLoader): 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) diff --git a/knack/arguments.py b/knack/arguments.py index 57ceb81..46d8f1d 100644 --- a/knack/arguments.py +++ b/knack/arguments.py @@ -317,6 +317,7 @@ def _get_experimental_arg_message(self): object_type = 'positional argument' experimental_info = ExperimentalItem( + self.command_loader.cli_ctx, target=target, object_type=object_type, message_func=_get_experimental_arg_message diff --git a/knack/commands.py b/knack/commands.py index 0427973..1b8b452 100644 --- a/knack/commands.py +++ b/knack/commands.py @@ -321,6 +321,7 @@ def __init__(self, command_loader, group_name, operations_tmpl, **kwargs): ) if is_experimental: kwargs['experimental_info'] = ExperimentalItem( + cli_ctx=self.command_loader.cli_ctx, target=group_name, object_type='command group' ) diff --git a/knack/experimental.py b/knack/experimental.py index 43f7afb..3105c2f 100644 --- a/knack/experimental.py +++ b/knack/experimental.py @@ -32,7 +32,7 @@ def _get_command_group(name): # pylint: disable=too-many-instance-attributes class ExperimentalItem(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 experimental metadata. :param cli_ctx: The CLI context associated with the experimental item. diff --git a/tests/test_experimental.py b/tests/test_experimental.py index 8b2da52..e519ee0 100644 --- a/tests/test_experimental.py +++ b/tests/test_experimental.py @@ -60,6 +60,15 @@ def load_arguments(self, command): """ 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. """