diff --git a/examples/exapp2 b/examples/exapp2 index 49a59e5..d6f7615 100644 --- a/examples/exapp2 +++ b/examples/exapp2 @@ -44,9 +44,34 @@ helps['abc last'] = """ text: {cli_name} abc last --number 3 """.format(cli_name=cli_name) +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 a_test_command_handler(): - return [{'a': 1, 'b': 1234}, {'a': 3, 'b': 4}] + +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(): @@ -64,7 +89,7 @@ def abc_last_command_handler(number=5): return list(string.ascii_lowercase)[-number:] -def num_range_command_handler(start=0, end=5): +def range_command_handler(start=0, end=5): """ Get a list of natural numbers from start to end :param start: the lower bound @@ -76,7 +101,7 @@ def num_range_command_handler(start=0, end=5): def sample_json_handler(): """ - Get a sample JSON dict + Get a sample JSON string """ # https://docs.microsoft.com/en-us/rest/api/resources/subscriptions/list#examples result = { @@ -99,8 +124,13 @@ def sample_json_handler(): } return result -def hello_command_handler(myarg=None, abc=None): - return ['hello', 'world', myarg, abc] + +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""" @@ -127,30 +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='1.0.0')) + 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', ) + g.command('last', 'abc_last_command_handler', is_experimental=True) with CommandGroup(self, 'ga', '__main__#{}') as g: - g.command('range', 'num_range_command_handler') + g.command('range', 'range_command_handler') with CommandGroup(self, 'pre', '__main__#{}', is_preview=True) as g: - g.command('range', 'num_range_command_handler') - with CommandGroup(self, 'exp', '__main__#{}', ) as g: - g.command('range', 'num_range_command_handler') + 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, ) + 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 8a5cc12..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. @@ -274,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): @@ -305,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(): @@ -316,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, @@ -330,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(): @@ -357,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, @@ -383,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(): @@ -400,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/commands.py b/knack/commands.py index ea57be0..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,12 +308,23 @@ 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' + ) command_loader._populate_command_group_table_with_subgroups(group_name) # pylint: disable=protected-access self.command_loader.command_group_table[group_name] = self @@ -325,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 71759f4..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]) diff --git a/knack/invocation.py b/knack/invocation.py index 6aab384..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 @@ -169,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 @@ -197,11 +202,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)) + 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/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..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/util.py b/tests/util.py index 30f7675..8768ef4 100644 --- a/tests/util.py +++ b/tests/util.py @@ -49,6 +49,10 @@ def wrapper(self): return wrapper +def remove_space(str): + return str.replace(' ', '').replace('\n', '') + + class MockContext(CLI): def __init__(self):