diff --git a/HISTORY.rst b/HISTORY.rst new file mode 100644 index 0000000..082ebad --- /dev/null +++ b/HISTORY.rst @@ -0,0 +1,105 @@ +.. :changelog: + +Release History +=============== + +0.6.2 ++++++ +* Adds ability to declare that command groups, commands, and arguments are in a preview status and therefore might change or be removed. This is done by passing the kwarg `is_preview=True`. +* Adds a generic `TagDecorator` class to `knack.util` that allows you to create your own colorized tags like `[Preview]` and `[Deprecated]`. + +0.6.1 ++++++ +* Always read from local for configured_default + +0.6.0 ++++++ +* Support local context chained config file + +0.5.4 ++++++ +* Allows the loading of text files using @filename syntax. +* Adds the argument kwarg configured_default to support setting argument defaults via the config file's [defaults] section or an environment variable. + +0.5.3 ++++++ +* Removes an incorrect check when adding arguments. + +0.5.2 ++++++ +* Updates usages of yaml.load to use yaml.safe_load. + +0.5.1 ++++++ +* Fix issue with some scenarios (no args and --version) + +0.5.0 ++++++ +* Adds support for positional arguments with the .positional helper method on ArgumentsContext. +* Removes the necessity for the type field in help.py. This information can be inferred from the class, so specifying it causes unnecessary crashes. +* Adds support for examining the result of a command after a call to invoke. The raw object, error (if any) an exit code are accessible. +* Adds support for accessing the command instance from inside custom commands by putting the special argument cmd in the signature. +* Fixes an issue with the default config directory. It use to be .cli and is now based on the CLI name. +* Fixes regression in knack 0.4.5 in behavior when cli_name --verbose/debug is used. Displays the welcome message as intended. +* Adds ability to specify line width for help text display. + +0.4.5 ++++++ +* Preserves logging verbosity and output format on the namespace for use by validators. + +0.4.4 ++++++ +* Adds ability to set config file name. +* Fixes bug with argument deprecations. + +0.4.3 ++++++ +* Fixes issue where values were sometimes ignored when using deprecated options regardless of which option was given. + +0.4.2 ++++++ +* Bug fix: disable number parse on table mode PR #88 + +0.4.1 ++++++ +* Fixes bug with deprecation mechanism. +* Fixes an issue where the command group table would only be filled by calls to create CommandGroup classes. This resulted in some gaps in the command group table. + +0.4.0 ++++++ +* Add mechanism to deprecate commands, command groups, arguments and argument options. +* Improve help display support for Unicode. + +0.3.3 ++++++ +* expose a callback to let client side perform extra logics (#80) +* output: don't skip false value on auto-tabulating (#83) + +0.3.2 ++++++ +* ArgumentsContext.ignore() should use hidden options_list (#76) +* Consolidate exception handling (#66) + +0.3.1 ++++++ +* Performance optimization - Delay import of platform and colorama (#47) +* CLIError: Inherit from Exception directly (#65) +* Explicitly state which packages to include (so exclude 'tests') (#68) + +0.2.0 ++++++ +* Support command level and argument level validators. +* knack.commands.CLICommandsLoader now accepts a command_cls argument so you can provide your own CLICommand class. +* logging: make determine_verbose_level private method. +* Allow overriding of NAMED_ARGUMENTS +* Only pass valid argparse kwargs to argparse.ArgumentParser.add_argument and ignore the rest +* logging: make determine_verbose_level private method +* Remove cli_command, register_cli_argument, register_extra_cli_argument as ways to register commands and arguments. + +0.1.1 ++++++ +* Add more types of command and argument loaders. + +0.1.0 ++++++ +* Initial release diff --git a/knack/arguments.py b/knack/arguments.py index 3f18de8..44f11d2 100644 --- a/knack/arguments.py +++ b/knack/arguments.py @@ -7,6 +7,7 @@ from collections import defaultdict from .deprecation import Deprecated +from .preview import PreviewItem from .log import get_logger from .util import CLIError @@ -42,7 +43,7 @@ def update(self, other=None, **kwargs): class CLICommandArgument(object): - NAMED_ARGUMENTS = ['options_list', 'validator', 'completer', 'arg_group', 'deprecate_info'] + NAMED_ARGUMENTS = ['options_list', 'validator', 'completer', 'arg_group', 'deprecate_info', 'preview_info'] def __init__(self, dest=None, argtype=None, **kwargs): """An argument that has a specific destination parameter. @@ -221,6 +222,55 @@ def __call__(self, parser, namespace, values, option_string=None): action = _handle_option_deprecation(deprecated_opts) return action + def _handle_previews(self, argument_dest, **kwargs): + + if not kwargs.get('is_preview', False): + return kwargs + + def _handle_argument_preview(preview_info): + + parent_class = self._get_parent_class(**kwargs) + + class PreviewArgumentAction(parent_class): + + def __call__(self, parser, namespace, values, option_string=None): + if not hasattr(namespace, '_argument_previews'): + setattr(namespace, '_argument_previews', [preview_info]) + else: + namespace._argument_previews.append(preview_info) # pylint: disable=protected-access + try: + super(PreviewArgumentAction, self).__call__(parser, namespace, values, option_string) + except NotImplementedError: + setattr(namespace, self.dest, values) + + return PreviewArgumentAction + + def _get_preview_arg_message(self): + return "{} '{}' is in preview. It may be changed/removed in a future release.".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)[0] + else: + # positional argument + target = kwargs.get('metavar', '<{}>'.format(argument_dest.upper())) + object_type = 'positional argument' + + preview_info = PreviewItem( + target=target, + object_type=object_type, + message_func=_get_preview_arg_message + ) + kwargs['preview_info'] = preview_info + kwargs['action'] = _handle_argument_preview(preview_info) + return kwargs + # pylint: disable=inconsistent-return-statements def deprecate(self, **kwargs): @@ -252,7 +302,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`. See /docs/arguments.md. + `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `deprecate_info`. + See /docs/arguments.md. """ self._check_stale() if not self._applicable(): @@ -261,6 +312,8 @@ def argument(self, argument_dest, arg_type=None, **kwargs): deprecate_action = self._handle_deprecations(argument_dest, **kwargs) if deprecate_action: kwargs['action'] = deprecate_action + + kwargs = self._handle_previews(argument_dest, **kwargs) self.command_loader.argument_registry.register_cli_argument(self.command_scope, argument_dest, arg_type, @@ -274,7 +327,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`. See /docs/arguments.md. + `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `deprecate_info`. + See /docs/arguments.md. """ self._check_stale() if not self._applicable(): @@ -293,11 +347,14 @@ def positional(self, argument_dest, arg_type=None, **kwargs): raise CLIError("command authoring error: commands may have, at most, one positional argument. '{}' already " "has positional argument: {}.".format(self.command_scope, ' '.join(positional_args.keys()))) + kwargs['options_list'] = [] + deprecate_action = self._handle_deprecations(argument_dest, **kwargs) if deprecate_action: kwargs['action'] = deprecate_action - kwargs['options_list'] = [] + kwargs = self._handle_previews(argument_dest, **kwargs) + self.command_loader.argument_registry.register_cli_argument(self.command_scope, argument_dest, arg_type, @@ -323,7 +380,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`. See /docs/arguments.md. + `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `deprecate_info`. + See /docs/arguments.md. """ self._check_stale() if not self._applicable(): @@ -337,6 +395,9 @@ def extra(self, argument_dest, **kwargs): deprecate_action = self._handle_deprecations(argument_dest, **kwargs) if deprecate_action: kwargs['action'] = deprecate_action + + kwargs = self._handle_previews(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 c3e1f73..50584a8 100644 --- a/knack/commands.py +++ b/knack/commands.py @@ -10,6 +10,7 @@ import six from .deprecation import Deprecated +from .preview import PreviewItem from .prompting import prompt_y_n, NoTTYException from .util import CLIError, CtxTypeError from .arguments import ArgumentRegistry, CLICommandArgument @@ -27,7 +28,8 @@ 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, **kwargs): + formatter_class=None, deprecate_info=None, validator=None, confirmation=None, preview_info=None, + **kwargs): """ The command object that goes into the command table. :param cli_ctx: CLI Context @@ -48,6 +50,8 @@ def __init__(self, cli_ctx, name, handler, description=None, table_transformer=N :type formatter_class: class :param deprecate_info: Deprecation message to display when this command is invoked :type deprecate_info: str + :param preview_info: Indicates a command is in preview + :type preview_info: bool :param validator: The command validator :param confirmation: User confirmation required for command :type confirmation: bool, str, callable @@ -66,6 +70,7 @@ def __init__(self, cli_ctx, name, handler, description=None, table_transformer=N self.table_transformer = table_transformer self.formatter_class = formatter_class self.deprecate_info = deprecate_info + self.preview_info = preview_info self.confirmation = confirmation self.validator = validator @@ -295,6 +300,11 @@ 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): + kwargs['preview_info'] = PreviewItem( + 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 @@ -313,7 +323,8 @@ def command(self, name, handler_name, **kwargs): :type handler_name: str :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`. + `formatter_class`, `table_transformer`, `deprecate_info`, `validator`, `confirmation`, + `is_preview`. """ import copy @@ -322,6 +333,11 @@ def command(self, name, handler_name, **kwargs): command_kwargs.update(kwargs) # don't inherit deprecation info from command group 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' + ) 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/deprecation.py b/knack/deprecation.py index c52ad27..f1fa7a1 100644 --- a/knack/deprecation.py +++ b/knack/deprecation.py @@ -5,6 +5,7 @@ from six import string_types as STRING_TYPES +from .util import TagDecorator DEFAULT_DEPRECATED_TAG = '[Deprecated]' @@ -29,25 +30,8 @@ def _get_command_group(name): return deprecate_info -class ColorizedString(object): - - def __init__(self, message, color): - import colorama - self._message = message - self._color = getattr(colorama.Fore, color.upper(), None) - - def __len__(self): - return len(self._message) - - def __str__(self): - import colorama - if not self._color: - return self._message - return self._color + self._message + colorama.Fore.RESET - - # pylint: disable=too-many-instance-attributes -class Deprecated(object): +class Deprecated(TagDecorator): @staticmethod def ensure_new_style_deprecation(cli_ctx, kwargs, object_type): @@ -62,7 +46,7 @@ def ensure_new_style_deprecation(cli_ctx, kwargs, object_type): return deprecate_info def __init__(self, cli_ctx=None, object_type='', target=None, redirect=None, hide=False, expiration=None, - tag_func=None, message_func=None): + tag_func=None, message_func=None, **kwargs): """ Create a collection of deprecation metadata. :param cli_ctx: The CLI context associated with the deprecated item. @@ -87,13 +71,6 @@ def __init__(self, cli_ctx=None, object_type='', target=None, redirect=None, hid Omit to use the default. :type message_func: callable """ - self.cli_ctx = cli_ctx - self.object_type = object_type - self.target = target - self.redirect = redirect - self.hide = hide - self.expiration = expiration - def _default_get_message(self): msg = "This {} has been deprecated and will be removed ".format(self.object_type) if self.expiration: @@ -104,24 +81,18 @@ def _default_get_message(self): msg += " Use '{}' instead.".format(self.redirect) return msg - self._get_tag = tag_func or (lambda _: DEFAULT_DEPRECATED_TAG) - self._get_message = message_func or _default_get_message - - def __deepcopy__(self, memo): - import copy - - cls = self.__class__ - result = cls.__new__(cls) - memo[id(self)] = result - for k, v in self.__dict__.items(): - try: - setattr(result, k, copy.deepcopy(v, memo)) - except TypeError: - if k == 'cli_ctx': - setattr(result, k, self.cli_ctx) - else: - raise - return result + self.redirect = redirect + self.hide = hide + self.expiration = expiration + + super(Deprecated, self).__init__( + cli_ctx=cli_ctx, + object_type=object_type, + target=target, + color='yellow', + tag_func=tag_func or (lambda _: DEFAULT_DEPRECATED_TAG), + message_func=message_func or _default_get_message + ) # pylint: disable=no-self-use def _version_less_than_or_equal_to(self, v1, v2): @@ -148,16 +119,6 @@ def hidden(self): def show_in_help(self): return not self.hidden() and not self.expired() - @property - def tag(self): - """ Returns a tag object. """ - return ColorizedString(self._get_tag(self), 'yellow') - - @property - def message(self): - """ Returns a tuple with the formatted message string and the message length. """ - return ColorizedString(self._get_message(self), 'yellow') - class ImplicitDeprecated(Deprecated): diff --git a/knack/help.py b/knack/help.py index e7de272..78570f7 100644 --- a/knack/help.py +++ b/knack/help.py @@ -10,6 +10,7 @@ from .deprecation import ImplicitDeprecated, resolve_deprecate_info from .log import get_logger +from .preview import ImplicitPreviewItem, resolve_preview_info from .util import CtxTypeError from .help_files import _load_help_file @@ -21,13 +22,6 @@ REQUIRED_TAG = '[Required]' -def _get_preview_tag(): - import colorama - PREVIEW_TAG = '{}[Preview]{}'.format(colorama.Fore.CYAN, colorama.Fore.RESET) - PREVIEW_TAG_LEN = len(PREVIEW_TAG) - 2 * len(colorama.Fore.RESET) - return (PREVIEW_TAG, PREVIEW_TAG_LEN) - - def _get_hanging_indent(max_length, indent): return max_length + (indent * 4) + len(FIRST_LINE_PREFIX) - 1 @@ -159,6 +153,26 @@ def __init__(self, help_ctx, delimiters): del deprecate_kwargs['_get_message'] self.deprecate_info = ImplicitDeprecated(**deprecate_kwargs) + # resolve preview info + direct_preview_info = resolve_preview_info(help_ctx.cli_ctx, delimiters) + if direct_preview_info: + self.preview_info = direct_preview_info + + # search for implicit preview + path_comps = delimiters.split()[:-1] + implicit_preview_info = None + while path_comps and not implicit_preview_info: + implicit_preview_info = resolve_preview_info(help_ctx.cli_ctx, ' '.join(path_comps)) + del path_comps[-1] + + if implicit_preview_info: + preview_kwargs = implicit_preview_info.__dict__.copy() + if delimiters in help_ctx.cli_ctx.invocation.commands_loader.command_table: + preview_kwargs['object_type'] = 'command' + else: + preview_kwargs['object_type'] = 'command group' + self.preview_info = ImplicitPreviewItem(**preview_kwargs) + def load(self, options): description = getattr(options, 'description', None) try: @@ -208,7 +222,6 @@ def __init__(self, help_ctx, delimiters, parser): super(GroupHelpFile, self).__init__(help_ctx, delimiters) self.type = 'group' - self.preview_info = getattr(parser, 'preview_info', None) self.children = [] if getattr(parser, 'choices', None): @@ -244,6 +257,7 @@ def __init__(self, help_ctx, delimiters, parser): param_kwargs = { 'name_source': [action.metavar or action.dest], 'deprecate_info': getattr(action, 'deprecate_info', None), + 'preview_info': getattr(action, 'preview_info', None), 'description': action.help, 'choices': action.choices, 'required': False, @@ -280,7 +294,8 @@ def _add_parameter_help(self, param): self.parameters.append(HelpParameter(**param_kwargs)) param_kwargs.update({ 'name_source': normal_options, - 'deprecate_info': getattr(param, 'deprecate_info', None) + 'deprecate_info': getattr(param, 'deprecate_info', None), + 'preview_info': getattr(param, 'preview_info', None) }) self.parameters.append(HelpParameter(**param_kwargs)) @@ -304,7 +319,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): + default=None, group_name=None, deprecate_info=None, preview_info=None): super(HelpParameter, self).__init__() self.name_source = name_source self.name = ' '.join(sorted(name_source)) @@ -317,6 +332,7 @@ def __init__(self, name_source, description, required, choices=None, self.default = default self.group_name = group_name self.deprecate_info = deprecate_info + self.preview_info = preview_info def update_from_data(self, data): if self.name != data.get('name'): @@ -367,6 +383,8 @@ def _build_long_summary(item): lines.append(item.long_summary) if item.deprecate_info: lines.append(str(item.deprecate_info.message)) + if item.preview_info: + lines.append(str(item.preview_info.message)) return ' '.join(lines) indent += 1 @@ -381,15 +399,18 @@ def _print_groups(self, help_file): self.max_line_len = 0 def _build_tags_string(item): - PREVIEW_TAG, PREVIEW_TAG_LEN = _get_preview_tag() + + preview_info = getattr(item, 'preview_info', None) + preview = preview_info.tag if preview_info else '' + deprecate_info = getattr(item, 'deprecate_info', None) deprecated = deprecate_info.tag if deprecate_info else '' - preview = PREVIEW_TAG if getattr(item, 'preview_info', None) else '' + required = REQUIRED_TAG if getattr(item, 'required', None) else '' - tags = ' '.join([x for x in [str(deprecated), preview, required] if x]) + tags = ' '.join([x for x in [str(deprecated), str(preview), required] if x]) tags_len = sum([ len(deprecated), - PREVIEW_TAG_LEN if preview else 0, + len(preview), len(required), tags.count(' ') ]) @@ -488,15 +509,18 @@ def _print_arguments(self, help_file): # pylint: disable=too-many-statements return None def _build_tags_string(item): - PREVIEW_TAG, PREVIEW_TAG_LEN = _get_preview_tag() + + preview_info = getattr(item, 'preview_info', None) + preview = preview_info.tag if preview_info else '' + deprecate_info = getattr(item, 'deprecate_info', None) deprecated = deprecate_info.tag if deprecate_info else '' - preview = PREVIEW_TAG if getattr(item, 'preview_info', None) else '' + required = REQUIRED_TAG if getattr(item, 'required', None) else '' - tags = ' '.join([x for x in [str(deprecated), preview, required] if x]) + tags = ' '.join([x for x in [str(deprecated), str(preview), required] if x]) tags_len = sum([ len(deprecated), - PREVIEW_TAG_LEN if preview else 0, + len(preview), len(required), tags.count(' ') ]) @@ -573,6 +597,9 @@ def _build_long_summary(item): deprecate_info = getattr(item, 'deprecate_info', None) if deprecate_info: lines.append(str(item.deprecate_info.message)) + preview_info = getattr(item, 'preview_info', None) + if preview_info: + lines.append(str(item.preview_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 f71f694..b217f90 100644 --- a/knack/invocation.py +++ b/knack/invocation.py @@ -10,6 +10,7 @@ from collections import defaultdict from .deprecation import ImplicitDeprecated, resolve_deprecate_info +from .preview import ImplicitPreviewItem, resolve_preview_info from .util import CLIError, CtxTypeError, CommandResultItem, todict from .parser import CLICommandParser from .commands import CLICommandsLoader @@ -117,6 +118,7 @@ def _validation(self, parsed_ns): err = sys.exc_info()[1] getattr(parsed_ns, '_parser', self.parser).validation_error(str(err)) + # pylint: disable=too-many-statements def execute(self, args): """ Executes the command invocation @@ -164,6 +166,10 @@ def execute(self, args): if cmd.deprecate_info: deprecations.append(cmd.deprecate_info) + previews = getattr(parsed_args, '_argument_previews', []) + if cmd.preview_info: + previews.append(cmd.preview_info) + params = self._filter_params(parsed_args) # search for implicit deprecation @@ -180,9 +186,23 @@ def execute(self, args): del deprecate_kwargs['_get_message'] deprecations.append(ImplicitDeprecated(**deprecate_kwargs)) + # search for implicit preview + path_comps = cmd.name.split()[:-1] + implicit_preview_info = None + while path_comps and not implicit_preview_info: + implicit_preview_info = resolve_preview_info(self.cli_ctx, ' '.join(path_comps)) + del path_comps[-1] + + if implicit_preview_info: + preview_kwargs = implicit_preview_info.__dict__.copy() + 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() cmd_result = parsed_args.func(params) diff --git a/knack/parser.py b/knack/parser.py index cefbceb..6ad4d0a 100644 --- a/knack/parser.py +++ b/knack/parser.py @@ -170,6 +170,7 @@ def load_command_table(self, command_loader): param = CLICommandParser._add_argument(command_parser, arg) param.completer = arg.completer param.deprecate_info = arg.deprecate_info + param.preview_info = arg.preview_info command_parser.set_defaults( func=metadata, command=command_name, diff --git a/knack/preview.py b/knack/preview.py new file mode 100644 index 0000000..bd3f02a --- /dev/null +++ b/knack/preview.py @@ -0,0 +1,77 @@ +# -------------------------------------------------------------------------------------------- +# 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 TagDecorator + +_PREVIEW_TAG = '[Preview]' +_preview_kwarg = 'preview_info' + + +def resolve_preview_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) + + preview_info = None + try: + command = _get_command(name) + preview_info = getattr(command, _preview_kwarg, None) + except KeyError: + command_group = _get_command_group(name) + group_kwargs = getattr(command_group, 'group_kwargs', None) + if group_kwargs: + preview_info = group_kwargs.get(_preview_kwarg, None) + return preview_info + + +# pylint: disable=too-many-instance-attributes +class PreviewItem(TagDecorator): + + def __init__(self, cli_ctx=None, 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. + :type cli_ctx: knack.cli.CLI + :param object_type: A label describing the type of object in preview. + :type: object_type: str + :param target: The name of the object in preview. + :type target: str + :param tag_func: Callable which returns the desired unformatted tag string for the preview item. + Omit to use the default. + :type tag_func: callable + :param message_func: Callable which returns the desired unformatted message string for the preview item. + Omit to use the default. + :type message_func: callable + """ + + def _default_get_message(self): + return "This {} is in preview. It may be changed/removed in a future release.".format(self.object_type) + + super(PreviewItem, self).__init__( + cli_ctx=cli_ctx, + object_type=object_type, + target=target, + color='cyan', + tag_func=tag_func or (lambda _: _PREVIEW_TAG), + message_func=message_func or _default_get_message + ) + + +class ImplicitPreviewItem(PreviewItem): + + def __init__(self, **kwargs): + + def get_implicit_preview_message(self): + return "Command group '{}' is in preview. It may be changed/removed " \ + "in a future release.".format(self.target) + + kwargs.update({ + 'tag_func': lambda _: '', + 'message_func': get_implicit_preview_message + }) + super(ImplicitPreviewItem, self).__init__(**kwargs) diff --git a/knack/util.py b/knack/util.py index ba9d3fc..22f71a7 100644 --- a/knack/util.py +++ b/knack/util.py @@ -35,6 +35,68 @@ def __init__(self, obj): obj.__class__.__name__)) +class ColorizedString(object): + + def __init__(self, message, color): + import colorama + self._message = message + self._color = getattr(colorama.Fore, color.upper(), None) + + def __len__(self): + return len(self._message) + + def __str__(self): + import colorama + if not self._color: + return self._message + return self._color + self._message + colorama.Fore.RESET + + +class TagDecorator(object): + + # pylint: disable=unused-argument + def __init__(self, cli_ctx, object_type, target, tag_func, message_func, color, **kwargs): + self.cli_ctx = cli_ctx + self.object_type = object_type + self.target = target + self._color = color + self._get_tag = tag_func + self._get_message = message_func + + def __deepcopy__(self, memo): + import copy + + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + for k, v in self.__dict__.items(): + try: + setattr(result, k, copy.deepcopy(v, memo)) + except TypeError: + if k == 'cli_ctx': + setattr(result, k, self.cli_ctx) + else: + raise + return result + + # pylint: disable=no-self-use + def hidden(self): + return False + + def show_in_help(self): + return not self.hidden() + + @property + def tag(self): + """ Returns a tag object. """ + return ColorizedString(self._get_tag(self), self._color) + + @property + def message(self): + """ Returns a tuple with the formatted message string and the message length. """ + return ColorizedString(self._get_message(self), self._color) + + def ensure_dir(d): """ Create a directory if it doesn't exist """ if not os.path.isdir(d): diff --git a/setup.py b/setup.py index 613f24b..065cd7a 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ from codecs import open from setuptools import setup, find_packages -VERSION = '0.6.1' +VERSION = '0.6.2' DEPENDENCIES = [ 'argcomplete', diff --git a/tests/test_preview.py b/tests/test_preview.py new file mode 100644 index 0000000..66b8459 --- /dev/null +++ b/tests/test_preview.py @@ -0,0 +1,196 @@ +# -------------------------------------------------------------------------------------------- +# 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 + +import unittest +try: + import mock +except ImportError: + from unittest import mock +from threading import Lock + +from knack.arguments import ArgumentsContext +from knack.commands import CLICommand, CLICommandsLoader, CommandGroup + +from tests.util import DummyCLI, redirect_io + + +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 TestCommandPreview(unittest.TestCase): + + def setUp(self): + + from knack.help_files import helps + + class PreviewTestCommandLoader(CLICommandsLoader): + def load_command_table(self, args): + super(PreviewTestCommandLoader, self).load_command_table(args) + with CommandGroup(self, '', '{}#{{}}'.format(__name__)) as g: + g.command('cmd1', 'example_handler', is_preview=True) + + with CommandGroup(self, 'grp1', '{}#{{}}'.format(__name__), is_preview=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(PreviewTestCommandLoader, self).load_arguments(command) + + helps['grp1'] = """ + type: group + short-summary: A group. +""" + self.cli_ctx = DummyCLI(commands_loader_cls=PreviewTestCommandLoader) + + @redirect_io + def test_preview_command_group_help(self): + """ Ensure preview 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 [Preview] : A group. + +Commands: + cmd1 [Preview] : Short summary here. + +""".format(self.cli_ctx.name) + self.assertEqual(expected, actual) + + @redirect_io + def test_preview_command_plain_execute(self): + """ Ensure general warning displayed when running preview command. """ + self.cli_ctx.invoke('cmd1 -b b'.split()) + actual = self.io.getvalue() + expected = "This command is in preview. It may be changed/removed in a future release." + self.assertIn(expected, actual) + + +class TestCommandGroupPreview(unittest.TestCase): + + def setUp(self): + + from knack.help_files import helps + + class PreviewTestCommandLoader(CLICommandsLoader): + def load_command_table(self, args): + super(PreviewTestCommandLoader, self).load_command_table(args) + + with CommandGroup(self, 'group1', '{}#{{}}'.format(__name__), is_preview=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(PreviewTestCommandLoader, self).load_arguments(command) + + helps['group1'] = """ + type: group + short-summary: A group. +""" + self.cli_ctx = DummyCLI(commands_loader_cls=PreviewTestCommandLoader) + + @redirect_io + def test_preview_command_group_help_plain(self): + """ Ensure help warnings appear for preview 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 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) + + @redirect_io + def test_preview_command_implicitly(self): + """ Ensure help warning displayed for command in preview because of a preview 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 in preview. It may be + changed/removed in a future release. +""".format(self.cli_ctx.name) + self.assertIn(expected, actual) + + +class TestArgumentPreview(unittest.TestCase): + + def setUp(self): + + from knack.help_files import helps + + class PreviewTestCommandLoader(CLICommandsLoader): + def load_command_table(self, args): + super(PreviewTestCommandLoader, 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_preview=True) + + super(PreviewTestCommandLoader, self).load_arguments(command) + + helps['grp1'] = """ + type: group + short-summary: A group. +""" + self.cli_ctx = DummyCLI(commands_loader_cls=PreviewTestCommandLoader) + + @redirect_io + def test_preview_arguments_command_help(self): + """ Ensure preview 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 [Preview] [Required] : Arg1. + 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. """ + self.cli_ctx.invoke('arg-test --arg1 foo --opt1 bar'.split()) + actual = self.io.getvalue() + expected = "Argument '--arg1' is in preview. It may be changed/removed in a future release." + self.assertIn(expected, actual) + + +if __name__ == '__main__': + unittest.main()