diff --git a/doc/how_to_introduce_breaking_changes.md b/doc/how_to_introduce_breaking_changes.md new file mode 100644 index 00000000000..38689a99bce --- /dev/null +++ b/doc/how_to_introduce_breaking_changes.md @@ -0,0 +1,295 @@ +# How to introduce Breaking Changes in service command + +Azure CLI has bi-annual breaking change releases coinciding with Microsoft **Build** and **Ignite**. Limiting breaking changes to twice a year provides a stable experience for customers while being able to keep up to date with the latest versions of Azure CLI and plan accordingly for announced breaking changes. + +## Breaking Changes in Azure CLI + +A breaking change refers to a modification that disrupts backward compatibility with previous versions. The breaking changes could cause a customer's script or automation written in a previous version to fail. + +The common examples of breaking changes include: +* Modifying the names of parameters/commands. +* Modifying the input logic of parameters. +* Modifying the format or properties of result output. +* Modifying the current behavior model. +* Adding additional verification that changes CLI behavior. + +To mitigate the impact of breaking changes, Azure CLI delays breaking changes and coordinates half-yearly **Breaking Change Releases** that bundle multiple breaking changes together. This approach helps users plan ahead and adapt to the modifications effectively. + +### Breaking Change Window + +The breaking change window is a designated sprint that **permits** the merging of service command breaking changes. Any Pull Request merged during this sprint will be included in the subsequent Breaking Change Release. + +The timing of the breaking change window in Azure CLI aligns with [Microsoft Build](https://build.microsoft.com/) and [Microsoft Ignite](https://ignite.microsoft.com/). It normally occurs in May for Build and November for Ignite. So please prepare beforehand to align command breaking changes with Azure CLI team accordingly. + +You could find the next Breaking Change Release plan in our [milestones](https://github.com/Azure/azure-cli/milestones). + +> It's highlighted that the introduction of breaking changes is typically prohibited within non-breaking-change window, based on what we stated above for consistency and stable tooling experience. +> +> Exceptions to this policy may be considered under extraordinary circumstances. We understand and would like to help out. There would be high-graded justifications required to provide the info Azure CLI can access. +> +> Please note that providing the required info for assessment does not mean it will be assured to be green-lighted for breaking changes. Team will still make the decision based on the overall impact. + +### Pre-announce Breaking Changes + +All breaking changes **must** be pre-announced two sprints ahead Release. It give users the buffer time ahead to mitigate for better command experience. There are two approaches to inform both interactive users and automatic users about the breaking changes. + +1. (**Mandatory**) Breaking Changes must be pre-announced through Warning Log while executing. +2. (*Automatic*) Breaking Changes would be collected automatically and listed in [Upcoming Breaking Change](https://learn.microsoft.com/en-us/cli/azure/upcoming-breaking-changes) Document. + +## Workflow + +### Overview + +* CLI Owned Module + * Service Team should create an Issue that requests CLI Team to create the pre-announcement several sprints ahead Breaking Change Window. The issue should include the label `Breaking Change`. The CLI team will look at the issue and evaluate if it will be accepted in the next breaking change release. + * Please ensure sufficient time for CLI Team to finish the pre-announcement. + * The pre-announcement should be released ahead of Breaking Change Window. +* Service Owned Module + * Service Team should create a Pull Request that create the pre-announcement several sprints ahead Breaking Change Window. + * The pre-announcement should be released ahead of Breaking Change Window. +* After releasing the pre-announcement, a pipeline would be triggered, and the Upcoming Breaking Change Documentation would be updated. +* At the start of Breaking Change window, the CLI team would notify Service Teams to adopt Breaking Changes. +* Breaking Changes should be adopted within Breaking Change Window. Any unfinished pre-announcements of breaking changes targeting this release will be deleted by the CLI team. + +### Pre-announce Breaking Changes + +The breaking change pre-announcement must be released at least two sprints before the breaking change itself. It is strongly recommended to follow the best practice of providing the new behavior along with the pre-announcement. This allows customers to take action as soon as they discover the pre-announcement. + +We provide several interfaces to pre-announce different types of breaking changes. + +To pre-announce breaking changes, such as modifications to default argument values, please add an entry to the `_breaking_change.py` file within the relevant module. If this file does not exist, create `_breaking_change.py` and insert the following lines. + +```python +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +``` + +You can then pre-announce breaking changes for different command groups or commands. Multiple breaking changes on the same command are accepted. + +```python +from azure.cli.core.breaking_change import register_required_flag_breaking_change, register_default_value_breaking_change, register_other_breaking_change + +register_required_flag_breaking_change('bar foo', '--name') +register_default_value_breaking_change('bar foo baz', '--foobar', 'A', 'B') +register_other_breaking_change('bar foo baz', 'During May 2024, another Breaking Change would happen in Build Event.') +``` + +All related breaking changes will be displayed while executing the command. For example, in the above declarations, the following warnings will be output when executing the command: + +```shell +# The azure command +az bar foo baz + +# =====Warning output===== +# The argument '--name' will become required in next breaking change release(2.61.0). +# The default value of '--foobar' will be changed to 'B' from 'A' in next breaking change release(2.61.0). +# During May 2024, another Breaking Change would happen in Build Event. +``` + +There are several types of breaking changes defined in `breaking_change.py`. You should use any of them to declare breaking changes in `_breaking_change.py`. + +**Deprecate** + +Declaring deprecation in `_breaking_change.py` is similar to deprecation when authoring commands. It is recommended to use this method rather than declaring `deprecate_info` when defining a command or argument. You can use the following method to declare deprecation: + +* `register_command_group_deprecate`: Deprecating a command group. +* `register_command_deprecate`: Deprecating a command. +* `register_argument_deprecate`: Deprecating an argument or option. + +> **Note:** Avoid marking an item with both a deprecation and another breaking change. A command group, command, or argument cannot be deprecated while also undergoing other breaking changes. + +They share similar arguments: + +* `command_group/command`: The name of the command group or command. +* `argument`: The name of the argument or option to be deprecated. If it is one of the options in `options_list`, the declaration will deprecate the option instead of the entire argument. +* `redirect`: This is the alternative that should be used in lieu of the deprecated thing. If not provided, the item is expected to be removed in the future with no replacement. +* `hide`: Hide the deprecated item from the help system, reducing discoverability, but still allow it to be used. Accepts either the boolean `True` to immediately hide the item or a core CLI version. If a version is supplied, the item will appear until the core CLI rolls to the specified value, after which it will be hidden. +* `target_version`: The version when the deprecated item should be removed. This version will be communicated in all warning messages. The `target_version` is the next breaking change window by default. The deprecated item will still function at the input version. + +```python +from azure.cli.core.breaking_change import register_command_group_deprecate, register_command_deprecate, register_argument_deprecate + +register_command_group_deprecate('bar', redirect='baz') +# Warning Message: This command group has been deprecated and will be removed in next breaking change release(2.67.0). Use `baz` instead. + +register_command_deprecate('bar foo', redirect='baz foo', hide=True) +# Warning Message: This command has been deprecated and will be removed in next breaking change release(2.67.0). Use `baz foo` instead. + +register_argument_deprecate('bar foo', '--name', target_version='2.70.0') +# Warning Message: Option `--name` has been deprecated and will be removed in 2.70.0. +``` + +> Note: The declared deprecation would be transformed into `knack.deprecation.Deprecated` item during runtime. The `tag_func` and `message_func` will remain effective. However, due to the timing of the transformation, the `expiration` will not take effect. + +**Remove** + +To declare the removal of an item, use the deprecation method instead. + +```python +from azure.cli.core.breaking_change import register_argument_deprecate + +register_argument_deprecate('bar foo', '--name', target_version='2.70.0') +# Warning Message: Option `--name` has been deprecated and will be removed in 2.70.0. +``` + +**Rename** + +To declare the renaming of an item, use the deprecation method. + +```python +from azure.cli.core.breaking_change import register_argument_deprecate + +register_argument_deprecate('bar foo', '--name', '--new-name') +# Warning Message: Option `--name` has been deprecated and will be removed in next breaking change release(2.67.0). Use `--new-name` instead. +``` + +**Output Change** + +Declare breaking changes that affect the output of a command. This ensures users are aware of modifications to the command’s output format or content. + +* `command`: The name of the command group or command. If it is a command group, the warning would show in the execution of all commands in the group. +* `description`: The description of the breaking change. The description will display in warning messages. +* `target_version`: The version when the deprecated item should be removed. The `target_version` is the next breaking change window by default. +* `guide`: The guide that customers could take action to prepare for the future breaking change. +* `doc_link`: A link to related documentation, which will be displayed in warning messages. + +```python +from azure.cli.core.breaking_change import register_output_breaking_change + +register_output_breaking_change('bar foo', description='Reduce the output field `baz`', + guide='You could retrieve this field through `az another command`.') +# The output will be changed in next breaking change release(2.61.0). Reduce the output field `baz`. You could retrieve this field through `az another command`. +``` + +**Logic Change** + +Declare breaking changes in the logic of the command. + +* `command`: The name of the command. +* `summary`: Summary of the breaking change, which will be displayed in warning messages. +* `target_version`: The version when the breaking change should happen. The `target_version` is the next breaking change window by default. +* `detail`: A detailed description of the breaking change, including the actions customers should take. +* `doc_link`: A link to related documentation, which will be displayed in warning messages. + +```python +from azure.cli.core.breaking_change import register_logic_breaking_change + +register_logic_breaking_change('bar foo', 'Update the validator', detail='The xxx will not be accepted.') +# Update the validator in next breaking change release(2.61.0). The xxx will not be accepted. +``` + +**Default Change** + +Declare breaking changes caused by changes in default values. This ensures users are aware of modifications to default values. + +* `command`: The name of the command. +* `arg`: The name of the argument or one of its options. The default change warning will display whether the argument is used or not. +* `current_default`: The current default value of the argument. +* `new_default`: The new default value of the argument. +* `target_version`: The version in which the breaking change should happen. By default, this is set to the next breaking change window. +* `target`: Use this field to overwrite the argument display in warning messages. +* `doc_link`: A link to related documentation, which will be displayed in warning messages. + +```python +from azure.cli.core.breaking_change import register_default_value_breaking_change + +register_default_value_breaking_change('bar foo', '--type', 'TypeA', 'TypeB') +# The default value of `--type` will be changed to `TypeB` from `TypeA` in next breaking change release(2.61.0). +``` + +**Be Required** + +Declare breaking changes that will make an argument required in a future release. This ensures users are aware of upcoming mandatory parameters. + +* `command`: The name of the command. +* `arg`: The name of the argument that will become required. +* `target_version`: The version in which the argument will become required. By default, this is set to the next breaking change window. +* `target`: Use this field to overwrite the argument display in warning messages. +* `doc_link`: A link to related documentation, which will be displayed in warning messages. + +```python +from azure.cli.core.breaking_change import register_required_flag_breaking_change + +register_required_flag_breaking_change('bar foo', '--type') +# The argument `--type` will become required in next breaking change release(2.61.0). +``` + +**Other Changes** + +Declare other custom breaking changes that do not fall into the predefined categories. This allows for flexibility in communicating various types of breaking changes to users. + +* `command`: The name of the command. +* `message`: A description of the breaking change. +* `arg`: The name of the argument associated with the breaking change. If arg is not None, the warning message will only be displayed when the argument is used. +* `target_version`: The version in which the breaking change will occur. By default, this is set to the next breaking change window. This value won't display in warning message but is used to generate upcoming breaking change document. + +```python +from azure.cli.core.breaking_change import register_other_breaking_change + +register_other_breaking_change('bar foo', 'During May 2024, another Breaking Change would happen in Build Event.') +# During May 2024, another Breaking Change would happen in Build Event. +``` + +**Conditional Breaking Change** + +To enhance flexibility, the CLI supports using a designated tag to specify a Breaking Change Pre-announcement. This method avoids reliance on the default automatic warning display and allows the warning to be shown whenever `print_manual_breaking_change` is called. + +**Note:** We strongly recommend using this method to display breaking change warnings under specific conditions instead of using `logger.warning` directly. This approach enables centralized documentation of breaking changes and assists in automating customer notifications. + +```python +# src/azure-cli/azure/cli/command_modules/vm/custom.py +from azure.cli.core.breaking_change import register_conditional_breaking_change, AzCLIOtherChange + +register_conditional_breaking_change(tag='SpecialBreakingChangeA', breaking_change=( + 'vm create', 'This is special Breaking Change Warning A. This breaking change is happend in "vm create" command.')) +register_conditional_breaking_change(tag='SpecialBreakingChangeB', breaking_change=( + 'vm', 'This is special Breaking Change Warning B. This breaking change is happend in "vm" command group.')) + + +# src/azure-cli/azure/cli/command_modules/vm/custom.py +def create_vm(cmd, vm_name, **): + from azure.cli.core.breaking_change import print_conditional_breaking_change + if some_condition: + print_conditional_breaking_change(cmd.cli_ctx, tag='SpecialBreakingChangeA', custom_logger=logger) + print_conditional_breaking_change(cmd.cli_ctx, tag='SpecialBreakingChangeB', custom_logger=logger) +``` + +This way, the pre-announcement wouldn't be display unless running into the branch, but still could be collected into the upcoming breaking change documentation. + +### Check Your Breaking Change Pre-Announcement + +Before you publish the breaking changes, you need to make sure that the announcement is ready for the Upcoming Breaking Change Documentation. To do that, run this command: + +```commandline +azdev breaking-change collect +``` + +If your breaking change is not for the next breaking change window, you can see all the announcements by using `--target-version None` like this: + +```commandline +azdev breaking-change collect --target-version None +``` + +The output should be a json object including the pre-announcement you made. + +### Adopt Breaking Changes + +Breaking changes should be released with the announced CLI version, typically during the next breaking change window. The breaking change Pull Request must be reviewed by a CLI team member and merged before the sprint’s code freeze day. + +**Note:** Ensure the breaking change pre-announcement is removed in the same Pull Request. + +## Upcoming Breaking Change Documentation + +The Upcoming Breaking Change Documentation is released every sprint. This document lists the expected breaking changes for the next Breaking Change Release. However, due to the implementation’s dependency on the Service Team, not all the listed Breaking Changes may be adopted. + +The Upcoming Breaking Change Documentation includes: +* The deprecation targeted at the next Breaking Change Release; +* The pre-announcement declared in `_breaking_change.py`. + +The documentation is generated through `azdev` tool. You can preview the documentation locally through the following command. + +```commandline +azdev breaking-change collect CLI --output-format markdown +``` diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 9e64706cda2..3bf648a1d4f 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -58,6 +58,7 @@ class AzCli(CLI): def __init__(self, **kwargs): super(AzCli, self).__init__(**kwargs) + from azure.cli.core.breaking_change import register_upcoming_breaking_change_info from azure.cli.core.commands import register_cache_arguments from azure.cli.core.commands.arm import ( register_ids_argument, register_global_subscription_argument) @@ -90,6 +91,7 @@ def __init__(self, **kwargs): register_global_subscription_argument(self) register_ids_argument(self) # global subscription must be registered first! register_cache_arguments(self) + register_upcoming_breaking_change_info(self) self.progress_controller = None @@ -218,6 +220,7 @@ def load_command_table(self, args): _load_module_command_loader, _load_extension_command_loader, BLOCKED_MODS, ExtensionCommandSource) from azure.cli.core.extension import ( get_extensions, get_extension_path, get_extension_modname) + from azure.cli.core.breaking_change import (import_module_breaking_changes, import_extension_breaking_changes) def _update_command_table_from_modules(args, command_modules=None): """Loads command tables from modules and merge into the main command table. @@ -254,6 +257,7 @@ def _update_command_table_from_modules(args, command_modules=None): try: start_time = timeit.default_timer() module_command_table, module_group_table = _load_module_command_loader(self, args, mod) + import_module_breaking_changes(mod) for cmd in module_command_table.values(): cmd.command_source = mod self.command_table.update(module_command_table) @@ -349,6 +353,7 @@ def _filter_modname(extensions): start_time = timeit.default_timer() extension_command_table, extension_group_table = \ _load_extension_command_loader(self, args, ext_mod) + import_extension_breaking_changes(ext_mod) for cmd_name, cmd in extension_command_table.items(): cmd.command_source = ExtensionCommandSource( diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index 62d2ae975ca..04987de97f4 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -250,6 +250,76 @@ def __init__(self, help_ctx, delimiters): super(CliHelpFile, self).__init__(help_ctx, delimiters) self.links = [] + from knack.deprecation import resolve_deprecate_info, ImplicitDeprecated, Deprecated + from azure.cli.core.breaking_change import UpcomingBreakingChangeTag, MergedStatusTag + direct_deprecate_info = None + breaking_changes = [] + deprecate_info = resolve_deprecate_info(help_ctx.cli_ctx, delimiters) + if isinstance(deprecate_info, Deprecated): + direct_deprecate_info = deprecate_info + elif isinstance(deprecate_info, UpcomingBreakingChangeTag): + breaking_changes.append(deprecate_info) + # If there are more than two `deprecate_info` and/or upcoming breaking changes, + # extract them and store separately from the merged status tag. + elif isinstance(deprecate_info, MergedStatusTag): + depr, bcs = CliHelpFile.classify_merged_status_tag(deprecate_info) + direct_deprecate_info = depr[0] if depr else None + breaking_changes.extend(bcs) + + # search for implicit deprecation + path_comps = delimiters.split()[:-1] + implicit_deprecate_info = None + while path_comps: + deprecate_info = resolve_deprecate_info(help_ctx.cli_ctx, ' '.join(path_comps)) + if isinstance(deprecate_info, Deprecated) and implicit_deprecate_info is None: + implicit_deprecate_info = deprecate_info + elif isinstance(deprecate_info, UpcomingBreakingChangeTag): + breaking_changes.append(deprecate_info) + # If there are more than two `deprecate_info` and/or upcoming breaking changes, + # extract them and store separately from the merged status tag. + elif isinstance(deprecate_info, MergedStatusTag): + depr, bcs = CliHelpFile.classify_merged_status_tag(deprecate_info) + if depr and implicit_deprecate_info is None: + implicit_deprecate_info = depr[0] + breaking_changes.extend(bcs) + del path_comps[-1] + + if implicit_deprecate_info: + deprecate_kwargs = implicit_deprecate_info.__dict__.copy() + deprecate_kwargs['object_type'] = 'command' if delimiters in \ + help_ctx.cli_ctx.invocation.commands_loader.command_table else 'command group' + del deprecate_kwargs['_get_tag'] + del deprecate_kwargs['_get_message'] + self.deprecate_info = ImplicitDeprecated(cli_ctx=help_ctx.cli_ctx, **deprecate_kwargs) + else: + self.deprecate_info = direct_deprecate_info + + all_deprecate_info = [self.deprecate_info] if self.deprecate_info else [] + all_deprecate_info.extend(breaking_changes) + if len(all_deprecate_info) > 1: + # Merge multiple `deprecate_info` and/or breaking changes so their messages can be displayed together. + self.deprecate_info = MergedStatusTag(help_ctx.cli_ctx, *all_deprecate_info) + elif all_deprecate_info: + self.deprecate_info = all_deprecate_info[0] + + @staticmethod + def classify_merged_status_tag(merged_status_tag): + from knack.deprecation import Deprecated + from azure.cli.core.breaking_change import UpcomingBreakingChangeTag, MergedStatusTag + + deprecate_info = [] + breaking_changes = [] + for tag in merged_status_tag.tags: + if isinstance(tag, Deprecated): + deprecate_info.append(tag) + elif isinstance(tag, UpcomingBreakingChangeTag): + breaking_changes.append(tag) + elif isinstance(tag, MergedStatusTag): + depr, bcs = CliHelpFile.classify_merged_status_tag(tag) + deprecate_info.extend(depr) + breaking_changes.extend(bcs) + return deprecate_info, breaking_changes + def _should_include_example(self, ex): supported_profiles = ex.get('supported-profiles') unsupported_profiles = ex.get('unsupported-profiles') diff --git a/src/azure-cli-core/azure/cli/core/breaking_change.py b/src/azure-cli-core/azure/cli/core/breaking_change.py new file mode 100644 index 00000000000..cb530d9ea1a --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/breaking_change.py @@ -0,0 +1,611 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +import abc +import argparse +from collections import defaultdict + +from knack.log import get_logger +from knack.deprecation import Deprecated +from knack.util import StatusTag, color_map + + +logger = get_logger() + +NEXT_BREAKING_CHANGE_RELEASE = '2.67.0' +DEFAULT_BREAKING_CHANGE_TAG = '[Breaking Change]' + + +def _get_action_class(cli_ctx, action): + action_class = argparse.Action + + # action is either a user-defined Action class or a string referring a library-defined Action + if isinstance(action, type) and issubclass(action, argparse.Action): + action_class = action + elif isinstance(action, str): + action_class = cli_ctx.invocation.command_loader.cli_ctx.invocation.parser._registries['action'][action] # pylint: disable=protected-access + return action_class + + +def _argument_breaking_change_action(cli_ctx, status_tag, action): + + action_class = _get_action_class(cli_ctx, action) + + class ArgumentBreakingChangeAction(action_class): + + def __call__(self, parser, namespace, values, option_string=None): + if not hasattr(namespace, '_argument_deprecations'): + setattr(namespace, '_argument_deprecations', []) + if isinstance(status_tag, MergedStatusTag): + for tag in status_tag.tags: + namespace._argument_deprecations.append(tag) + else: + if status_tag not in namespace._argument_deprecations: + namespace._argument_deprecations.append(status_tag) + try: + super().__call__(parser, namespace, values, option_string) + except NotImplementedError: + setattr(namespace, self.dest, values) + + return ArgumentBreakingChangeAction + + +def _find_arg(arg_name, arguments): + if arg_name in arguments: + return arg_name, arguments[arg_name] + for key, argument in arguments.items(): + for option in argument.options_list or []: + if arg_name == option or (isinstance(option, Deprecated) and arg_name == option.target): + return key, argument + trimmed_arg_name = arg_name.strip('-').replace('-', '_') + if trimmed_arg_name in arguments: + return trimmed_arg_name, arguments[trimmed_arg_name] + return None, None + + +class UpcomingBreakingChangeTag(StatusTag): + def __init__(self, cli_ctx, object_type='', target=None, target_version=None, tag_func=None, message_func=None, + always_display=False): + def _default_get_message(bc): + msg = f"A breaking change may occur to this {bc.object_type} " + if isinstance(target_version, TargetVersion): + msg += str(target_version) + '.' + elif isinstance(target_version, str): + msg += 'in ' + target_version + '.' + else: + msg += 'in future release.' + return msg + + self.always_display = always_display + self.target_version = target_version + super().__init__( + cli_ctx=cli_ctx, + object_type=object_type, + target=target, + color=color_map['deprecation'], + tag_func=tag_func or (lambda _: DEFAULT_BREAKING_CHANGE_TAG), + message_func=message_func or _default_get_message + ) + + def expired(self): + return False + + +class MergedStatusTag(StatusTag): + # This class is used to merge multiple status tags into one. + # It is particularly useful when multiple breaking changes and deprecation information need to be recorded + # in a single deprecate_info field. + + def __init__(self, cli_ctx, *tags): + assert len(tags) > 0 + tag = tags[0] + self.tags = list(tags) + + def _get_merged_tag(self): + return ''.join({tag._get_tag(self) for tag in self.tags}) # pylint: disable=protected-access + + def _get_merged_msg(self): + return '\n'.join({tag._get_message(self) for tag in self.tags}) # pylint: disable=protected-access + + super().__init__(cli_ctx, tag.object_type, tag.target, tag_func=_get_merged_tag, + message_func=_get_merged_msg, color=tag._color) + + def merge(self, other): + self.tags.append(other) + + def hidden(self): + return any(tag.hidden() for tag in self.tags) + + def show_in_help(self): + return any(tag.show_in_help() for tag in self.tags) + + def expired(self): + return any(tag.expired() for tag in self.tags) + + @property + def tag(self): + return ''.join({str(tag.tag) for tag in self.tags}) + + @property + def message(self): + return '\n'.join({str(tag.message) for tag in self.tags}) + + +def _next_breaking_change_version(): + return NEXT_BREAKING_CHANGE_RELEASE + + +# pylint: disable=too-few-public-methods +class TargetVersion(abc.ABC): + @abc.abstractmethod + def __str__(self): + raise NotImplementedError() + + @abc.abstractmethod + def version(self): + raise NotImplementedError() + + +# pylint: disable=too-few-public-methods +class NextBreakingChangeWindow(TargetVersion): + def __str__(self): + next_breaking_change_version = _next_breaking_change_version() + if next_breaking_change_version: + return f'in next breaking change release({next_breaking_change_version})' + return 'in next breaking change release' + + def version(self): + return _next_breaking_change_version() + + +# pylint: disable=too-few-public-methods +class ExactVersion(TargetVersion): + def __init__(self, version): + self.version = version + + def __str__(self): + return f'in {self.version}' + + def version(self): + return self.version() + + +# pylint: disable=too-few-public-methods +class UnspecificVersion(TargetVersion): + def __str__(self): + return 'in a future release' + + def version(self): + return None + + +class BreakingChange(abc.ABC): + def __init__(self, cmd, arg=None, target=None, target_version=None): + self.cmd = cmd + if isinstance(arg, str): + self.args = [arg] + elif isinstance(arg, list): + self.args = arg + else: + self.args = [] + self.target = target if target else '/'.join(self.args) if self.args else self.cmd + if isinstance(target_version, TargetVersion): + self._target_version = target_version + elif isinstance(target_version, str): + self._target_version = ExactVersion(target_version) + else: + self._target_version = UnspecificVersion() + + @property + def message(self): + return '' + + @property + def target_version(self): + return self._target_version + + @staticmethod + def format_doc_link(doc_link): + return f' To know more about the Breaking Change, please visit {doc_link}.' if doc_link else '' + + @property + def command_name(self): + if self.cmd.startswith('az '): + return self.cmd[3:].strip() + return self.cmd + + def is_command_group(self, cli_ctx): + return self.command_name in cli_ctx.invocation.commands_loader.command_group_table + + def to_tag(self, cli_ctx, **kwargs): + if self.args: + object_type = 'argument' + elif self.is_command_group(cli_ctx): + object_type = 'command group' + else: + object_type = 'command' + tag_kwargs = { + 'object_type': object_type, + 'target': self.target, + 'target_version': self.target_version, + 'message_func': lambda _: self.message, + } + tag_kwargs.update(kwargs) + return UpcomingBreakingChangeTag(cli_ctx, **tag_kwargs) + + def register(self, cli_ctx): + if self.args: + command = cli_ctx.invocation.commands_loader.command_table.get(self.command_name) + if not command: + return + for arg_name in self.args: + if self._register_option_deprecate(cli_ctx, command.arguments, arg_name): + continue + arg_name, arg = _find_arg(arg_name, command.arguments) + if not arg: + continue + arg.deprecate_info = self.appended_status_tag(cli_ctx, arg.deprecate_info, self.to_tag(cli_ctx)) + arg.action = _argument_breaking_change_action(cli_ctx, arg.deprecate_info, arg.options['action']) + elif self.is_command_group(cli_ctx): + command_group = cli_ctx.invocation.commands_loader.command_group_table[self.command_name] + if not command_group: + self._register_to_direct_sub_cg_or_command(cli_ctx, self.command_name, self.to_tag(cli_ctx)) + else: + command_group.group_kwargs['deprecate_info'] = \ + self.appended_status_tag(cli_ctx, command_group.group_kwargs.get('deprecate_info'), + self.to_tag(cli_ctx)) + else: + command = cli_ctx.invocation.commands_loader.command_table.get(self.cmd) + if not command: + return + command.deprecate_info = self.appended_status_tag(cli_ctx, command.deprecate_info, self.to_tag(cli_ctx)) + + @staticmethod + def appended_status_tag(cli_ctx, old_status_tag, new_status_tag): + if isinstance(old_status_tag, (Deprecated, UpcomingBreakingChangeTag)): + return MergedStatusTag(cli_ctx, old_status_tag, new_status_tag) + if isinstance(old_status_tag, MergedStatusTag): + old_status_tag.merge(new_status_tag) + return old_status_tag + return new_status_tag + + def _register_to_direct_sub_cg_or_command(self, cli_ctx, cg_name, status_tag): + for key, command_group in cli_ctx.invocation.commands_loader.command_group_table.items(): + split_key = key.rsplit(maxsplit=1) + # If inpass command group name is empty, all first level command group should be registered to. + # Otherwise, we need to find all direct sub command groups. + if (not cg_name and len(split_key) == 1) or (len(split_key) == 2 and split_key[0] == cg_name): + from azure.cli.core.commands import AzCommandGroup + if isinstance(command_group, AzCommandGroup): + command_group.group_kwargs['deprecate_info'] = \ + self.appended_status_tag(cli_ctx, command_group.group_kwargs.get('deprecate_info'), status_tag) + else: + self._register_to_direct_sub_cg_or_command(cli_ctx, key, status_tag) + for key, command in cli_ctx.invocation.commands_loader.command_table.items(): + # If inpass command group name is empty, all first level command should be registered to. + if (not cg_name and ' ' not in key) or key.rsplit(maxsplit=1)[0] == cg_name: + command.deprecate_info = self.appended_status_tag(cli_ctx, command.deprecate_info, self.to_tag(cli_ctx)) + + def _register_option_deprecate(self, cli_ctx, arguments, option_name): + for _, argument in arguments.items(): + if argument.options_list and len(argument.options_list) > 1: + for idx, option in enumerate(argument.options_list): + if isinstance(option, str) and option_name == option and isinstance(self, AzCLIDeprecate): + if isinstance(argument.options_list, tuple): + # Some of the command would declare options_list as tuple + argument.options_list = list(argument.options_list) + argument.options_list[idx] = self.to_tag(cli_ctx, object_type='option') + argument.options_list[idx].target = option + argument.action = _argument_breaking_change_action(cli_ctx, argument.options_list[idx], + argument.options['action']) + return True + return False + + +class AzCLIDeprecate(BreakingChange): + def __init__(self, cmd, arg=None, target_version=NextBreakingChangeWindow(), **kwargs): + super().__init__(cmd, arg, None, target_version) + self.kwargs = kwargs + + @staticmethod + def _build_message(object_type, target, target_version, redirect): + if object_type in ['argument', 'option']: + msg = "{} '{}' has been deprecated and will be removed ".format(object_type, target).capitalize() + elif object_type: + msg = "This {} has been deprecated and will be removed ".format(object_type) + else: + msg = "`{}` has been deprecated and will be removed ".format(target) + msg += str(target_version) + '.' + if redirect: + msg += " Use '{}' instead.".format(redirect) + return msg + + @property + def message(self): + return self._build_message(self.kwargs.get('object_type'), self.target, self.kwargs.get('expiration'), + self.kwargs.get('redirect')) + + def to_tag(self, cli_ctx, **kwargs): + if self.args: + object_type = 'argument' + elif self.is_command_group(cli_ctx): + object_type = 'command group' + else: + object_type = 'command' + tag_kwargs = { + 'object_type': object_type, + 'message_func': lambda depr: self._build_message( + depr.object_type, depr.target, self.target_version, depr.redirect), + 'target': self.target + } + tag_kwargs.update(self.kwargs) + tag_kwargs.update(kwargs) + return Deprecated(cli_ctx, **tag_kwargs) + + +class AzCLIRemoveChange(BreakingChange): + """ + Remove the command groups, commands or arguments in a future release. + + **It is recommended to utilize `deprecate_info` instead of this class to pre-announce Breaking Change of Removal.** + :param target: name of the removed command group, command or argument + :param target_version: version where the breaking change is expected to happen. + :type target_version: TargetVersion + :param redirect: alternative way to replace the old behavior + :param doc_link: link of the related document + """ + + def __init__(self, cmd, arg=None, target_version=NextBreakingChangeWindow(), target=None, redirect=None, + doc_link=None): + super().__init__(cmd, arg, target, target_version) + self.alter = redirect + self.doc_link = doc_link + + @property + def message(self): + alter = f" Please use '{self.alter}' instead." if self.alter else '' + doc = self.format_doc_link(self.doc_link) + return f"'{self.target}' will be removed {str(self._target_version)}.{alter}{doc}" + + +class AzCLIRenameChange(BreakingChange): + """ + Rename the command groups, commands or arguments to a new name in a future release. + + **It is recommended to utilize `deprecate_info` instead of this class to pre-announce Breaking Change of Renaming.** + It is recommended that the old name and the new name should be reserved in few releases. + :param target: name of the renamed command group, command or argument + :param new_name: new name + :param target_version: version where the breaking change is expected to happen. + :type target_version: TargetVersion + :param doc_link: link of the related document + """ + + def __init__(self, cmd, new_name, arg=None, target=None, target_version=NextBreakingChangeWindow(), doc_link=None): + super().__init__(cmd, arg, target, target_version) + self.new_name = new_name + self.doc_link = doc_link + + @property + def message(self): + doc = self.format_doc_link(self.doc_link) + return f"'{self.target}' will be renamed to '{self.new_name}' {str(self._target_version)}.{doc}" + + +class AzCLIOutputChange(BreakingChange): + """ + The output of the command will be changed in a future release. + :param description: describe the changes in output + :param target_version: version where the breaking change is expected to happen. + :type target_version: TargetVersion + :param guide: how to adapt to the change + :param doc_link: link of the related document + """ + + def __init__(self, cmd, description: str, target_version=NextBreakingChangeWindow(), guide=None, doc_link=None): + super().__init__(cmd, None, None, target_version) + self.desc = description + self.guide = guide + self.doc_link = doc_link + + @property + def message(self): + desc = self.desc.rstrip() + if desc and desc[-1] not in ',.;?!': + desc = desc + '.' + if self.guide: + guide = self.guide.rstrip() + if guide and guide[-1] not in ',.;?!': + guide = ' ' + guide + '.' + else: + guide = '' + doc = self.format_doc_link(self.doc_link) + return f'The output will be changed {str(self.target_version)}. {desc}{guide}{doc}' + + +class AzCLILogicChange(BreakingChange): + """ + There would be a breaking change in the logic of the command in future release. + :param summary: a short summary about the breaking change + :param target_version: version where the breaking change is expected to happen. + :type target_version: TargetVersion + :param detail: detailed information + :param doc_link: link of the related document + """ + + def __init__(self, cmd, summary, target_version=NextBreakingChangeWindow(), detail=None, doc_link=None): + super().__init__(cmd, None, None, target_version) + self.summary = summary + self.detail = detail + self.doc_link = doc_link + + @property + def message(self): + detail = f' {self.detail}' if self.detail else '' + return f'{self.summary} {str(self.target_version)}.{detail}{self.format_doc_link(self.doc_link)}' + + +class AzCLIDefaultChange(BreakingChange): + """ + The default value of an argument would be changed in a future release. + :param target: name of the related argument + :param current_default: current default value of the argument + :param new_default: new default value of the argument + :param target_version: version where the breaking change is expected to happen. + :type target_version: TargetVersion + :param doc_link: link of the related document + """ + + def __init__(self, cmd, arg, current_default, new_default, target_version=NextBreakingChangeWindow(), + target=None, doc_link=None): + super().__init__(cmd, arg, target, target_version) + self.current_default = current_default + self.new_default = new_default + self.doc_link = doc_link + + @property + def message(self): + doc = self.format_doc_link(self.doc_link) + return (f"The default value of '{self.target}' will be changed to '{self.new_default}' from " + f"'{self.current_default}' {str(self._target_version)}.{doc}") + + def to_tag(self, cli_ctx, **kwargs): + if 'always_display' not in kwargs: + kwargs['always_display'] = True + return super().to_tag(cli_ctx, **kwargs) + + +class AzCLIBeRequired(BreakingChange): + """ + The argument would become required in a future release. + :param target: name of the related argument + :param target_version: version where the breaking change is expected to happen. + :type target_version: TargetVersion + :param doc_link: link of the related document + """ + + def __init__(self, cmd, arg, target_version=NextBreakingChangeWindow(), target=None, doc_link=None): + super().__init__(cmd, arg, target, target_version) + self.doc_link = doc_link + + @property + def message(self): + doc = self.format_doc_link(self.doc_link) + return f"The argument '{self.target}' will become required {str(self._target_version)}.{doc}" + + def to_tag(self, cli_ctx, **kwargs): + if 'always_display' not in kwargs: + kwargs['always_display'] = True + return super().to_tag(cli_ctx, **kwargs) + + +class AzCLIOtherChange(BreakingChange): + """ + Other custom breaking changes. + :param message: A description of the breaking change, including the version number where it is expected to occur. + :param target_version: version where the breaking change is expected to happen. + :type target_version: TargetVersion + """ + + def __init__(self, cmd, message, arg=None, target_version=NextBreakingChangeWindow()): + super().__init__(cmd, arg, None, target_version) + self._message = message + + @property + def message(self): + return self._message + + +upcoming_breaking_changes = defaultdict(lambda: []) + + +def import_module_breaking_changes(mod): + try: + from importlib import import_module + import_module('azure.cli.command_modules.' + mod + '._breaking_change') + except ImportError: + pass + + +def import_extension_breaking_changes(ext_mod): + try: + from importlib import import_module + import_module(ext_mod + '._breaking_change') + except ImportError: + pass + + +def register_upcoming_breaking_change_info(cli_ctx): + from knack import events + + def update_breaking_change_info(cli_ctx, **kwargs): # pylint: disable=unused-argument + for key, breaking_changes in upcoming_breaking_changes.items(): + # Conditional Breaking Changes are announced with key `CommandName.Tag`. They should not be registered. + if '.' in key: + continue + for breaking_change in breaking_changes: + breaking_change.register(cli_ctx) + + cli_ctx.register_event(events.EVENT_INVOKER_POST_CMD_TBL_CREATE, update_breaking_change_info) + + +def register_deprecate_info(command_name, arg=None, target_version=NextBreakingChangeWindow(), **kwargs): + upcoming_breaking_changes[command_name].append(AzCLIDeprecate(command_name, arg, target_version, **kwargs)) + + +def register_output_breaking_change(command_name, description, target_version=NextBreakingChangeWindow(), guide=None, + doc_link=None): + upcoming_breaking_changes[command_name].append( + AzCLIOutputChange(command_name, description, target_version, guide, doc_link)) + + +def register_logic_breaking_change(command_name, summary, target_version=NextBreakingChangeWindow(), detail=None, + doc_link=None): + upcoming_breaking_changes[command_name].append( + AzCLILogicChange(command_name, summary, target_version, detail, doc_link)) + + +def register_default_value_breaking_change(command_name, arg, current_default, new_default, + target_version=NextBreakingChangeWindow(), target=None, doc_link=None): + upcoming_breaking_changes[command_name].append( + AzCLIDefaultChange(command_name, arg, current_default, new_default, target_version, target, doc_link)) + + +def register_required_flag_breaking_change(command_name, arg, target_version=NextBreakingChangeWindow(), target=None, + doc_link=None): + upcoming_breaking_changes[command_name].append(AzCLIBeRequired(command_name, arg, target_version, target, doc_link)) + + +def register_other_breaking_change(command_name, message, arg=None, target_version=NextBreakingChangeWindow()): + upcoming_breaking_changes[command_name].append(AzCLIOtherChange(command_name, message, arg, target_version)) + + +def register_command_group_deprecate(command_group, redirect=None, hide=None, + target_version=NextBreakingChangeWindow(), **kwargs): + register_deprecate_info(command_group, redirect=redirect, hide=hide, target_version=target_version, **kwargs) + + +def register_command_deprecate(command, redirect=None, hide=None, + target_version=NextBreakingChangeWindow(), **kwargs): + register_deprecate_info(command, redirect=redirect, hide=hide, target_version=target_version, **kwargs) + + +def register_argument_deprecate(command, argument, redirect=None, hide=None, + target_version=NextBreakingChangeWindow(), **kwargs): + register_deprecate_info(command, argument, redirect=redirect, hide=hide, target_version=target_version, **kwargs) + + +def register_conditional_breaking_change(tag, breaking_change): + upcoming_breaking_changes[breaking_change.command_name + '.' + tag].append(breaking_change) + + +def print_conditional_breaking_change(cli_ctx, tag, custom_logger=None): + command = cli_ctx.invocation.commands_loader.command_name + custom_logger = custom_logger or logger + + command_comps = command.split() + while command_comps: + for breaking_change in upcoming_breaking_changes.get(' '.join(command_comps) + '.' + tag, []): + custom_logger.warning(breaking_change.message) + del command_comps[-1] diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 26bb7237db8..ac58b88d4ae 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -14,8 +14,10 @@ import sys import time import copy +from collections import OrderedDict from importlib import import_module +from azure.cli.core.breaking_change import UpcomingBreakingChangeTag, MergedStatusTag # pylint: disable=unused-import from azure.cli.core.commands.constants import ( BLOCKED_MODS, DEFAULT_QUERY_TIME_RANGE, CLI_COMMON_KWARGS, CLI_COMMAND_KWARGS, CLI_PARAM_KWARGS, @@ -31,7 +33,7 @@ from knack.arguments import CLICommandArgument from knack.commands import CLICommand, CommandGroup, PREVIEW_EXPERIMENTAL_CONFLICT_ERROR -from knack.deprecation import ImplicitDeprecated, resolve_deprecate_info +from knack.deprecation import ImplicitDeprecated, resolve_deprecate_info, Deprecated from knack.invocation import CommandInvoker from knack.preview import ImplicitPreviewItem, PreviewItem, resolve_preview_info from knack.experimental import ImplicitExperimentalItem, ExperimentalItem, resolve_experimental_info @@ -751,15 +753,36 @@ def resolve_warnings(self, cmd, parsed_args): self._resolve_extension_override_warning(cmd) def _resolve_preview_and_deprecation_warnings(self, cmd, parsed_args): - deprecations = [] + getattr(parsed_args, '_argument_deprecations', []) + deprecations = getattr(parsed_args, '_argument_deprecations', []) + # Handle `always_display` argument breaking changes + for _, argument in parsed_args.func.arguments.items(): + # Some arguments have breaking changes that must always be displayed. + # Iterate through them and show the warnings. + if isinstance(argument.deprecate_info, UpcomingBreakingChangeTag): + if argument.deprecate_info.always_display: + deprecations.append(argument.deprecate_info) + elif isinstance(argument.deprecate_info, MergedStatusTag): + for deprecation in argument.deprecate_info.tags: + if isinstance(deprecation, UpcomingBreakingChangeTag) and deprecation.always_display: + deprecations.append(deprecation) + # Dedup the deprecations + # If an argument has multiple breaking changes or deprecations, + # duplicated deprecations would be produced due to the inherent logic of action + deprecations = list(OrderedDict.fromkeys(deprecations)) if cmd.deprecate_info: deprecations.append(cmd.deprecate_info) # search for implicit deprecation path_comps = cmd.name.split()[:-1] implicit_deprecate_info = None - while path_comps and not implicit_deprecate_info: - implicit_deprecate_info = resolve_deprecate_info(self.cli_ctx, ' '.join(path_comps)) + while path_comps: + deprecate_info = resolve_deprecate_info(self.cli_ctx, ' '.join(path_comps)) + if isinstance(deprecate_info, Deprecated) and implicit_deprecate_info is None: + implicit_deprecate_info = deprecate_info + elif isinstance(deprecate_info, UpcomingBreakingChangeTag): + deprecations.append(deprecate_info) + elif isinstance(deprecate_info, MergedStatusTag): + deprecations.extend(deprecate_info.tags) del path_comps[-1] if implicit_deprecate_info: diff --git a/src/azure-cli-core/azure/cli/core/tests/test_breaking_change.py b/src/azure-cli-core/azure/cli/core/tests/test_breaking_change.py new file mode 100644 index 00000000000..223e12eeb11 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/tests/test_breaking_change.py @@ -0,0 +1,361 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +import io +import logging +import unittest +from unittest import mock + +from azure.cli.core import AzCommandsLoader +from azure.cli.core.mock import DummyCli + + +class TestCommandsLoader(AzCommandsLoader): + def __init__(self, cli_ctx=None, command_group_cls=None, argument_context_cls=None, suppress_extension=None, + **kwargs): + super().__init__(cli_ctx, command_group_cls, argument_context_cls, suppress_extension, **kwargs) + self.cmd_to_loader_map = {} + + def load_command_table(self, args): + super(TestCommandsLoader, self).load_command_table(args) + with self.command_group('test group', operations_tmpl='{}#TestCommandsLoader.{{}}'.format(__name__)) as g: + g.command('cmd', '_test_command') + self.cmd_to_loader_map['test group cmd'] = [self] + + return self.command_table + + def load_arguments(self, command): + super(TestCommandsLoader, self).load_arguments(command) + with self.argument_context('test group cmd') as c: + c.argument('arg1', options_list=('--arg1', '--arg1-alias', '-a')) + c.argument('arg2', options_list=('--arg2', '--arg2-alias', '--arg2-alias-long')) + self._update_command_definitions() + + @staticmethod + def _test_command(arg1=None, arg2=None): + pass + + +class TestBreakingChange(unittest.TestCase): + def setUp(self): + super().setUp() + from azure.cli.core.breaking_change import upcoming_breaking_changes + for key in upcoming_breaking_changes: + upcoming_breaking_changes[key] = [] + + def test_register_and_execute(self): + from contextlib import redirect_stderr, redirect_stdout + + from azure.cli.core.breaking_change import register_other_breaking_change + + warning_message = 'Test Breaking Change in Test Group' + register_other_breaking_change('test', warning_message) + cli = DummyCli(commands_loader_cls=TestCommandsLoader) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd', '--arg1', '1', '--arg2', '2']) + self.assertEqual(f'WARNING: {warning_message}\n', captured_err.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', 'cmd', '--help']) + self.assertIn(warning_message, captured_output.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', '--help']) + self.assertIn('[Breaking Change]', captured_output.getvalue()) + self.assertIn(warning_message, captured_output.getvalue()) + + def test_command_group_deprecate(self): + from contextlib import redirect_stderr, redirect_stdout + + from azure.cli.core.breaking_change import register_command_group_deprecate + + register_command_group_deprecate('test group', redirect='test group1', target_version='2.70.0') + implicit_warning = "This command is implicitly deprecated because command group 'test "\ + "group' is deprecated and will be removed in a future release. Use 'test "\ + "group1' instead." + warning = "This command group has been deprecated and will be removed in 2.70.0. Use \'test group1\' instead." + cli = DummyCli(commands_loader_cls=TestCommandsLoader) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd', '--arg1', '1', '--arg2', '2']) + self.assertEqual(f'WARNING: {implicit_warning}\n', captured_err.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', 'cmd', '--help']) + self.assertIn(implicit_warning, captured_output.getvalue().replace('\n ', ' ')) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', '--help']) + self.assertIn(warning, captured_output.getvalue().replace('\n ', ' ')) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', '--help']) + self.assertIn('[Deprecated]', captured_output.getvalue()) + + def test_command_deprecate(self): + from contextlib import redirect_stderr, redirect_stdout + + from azure.cli.core.breaking_change import register_command_deprecate + + register_command_deprecate('test group cmd', redirect='test group cmd1', target_version='2.70.0') + warning = "This command has been deprecated and will be removed in 2.70.0. Use \'test group cmd1\' instead." + cli = DummyCli(commands_loader_cls=TestCommandsLoader) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd', '--arg1', '1', '--arg2', '2']) + self.assertEqual(f'WARNING: {warning}\n', captured_err.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', 'cmd', '--help']) + self.assertIn(warning, captured_output.getvalue().replace('\n ', ' ')) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', '--help']) + self.assertIn('[Deprecated]', captured_output.getvalue()) + + @mock.patch('azure.cli.core.breaking_change.NEXT_BREAKING_CHANGE_RELEASE', '3.0.0') + def test_argument_deprecate(self): + from contextlib import redirect_stderr, redirect_stdout + + from azure.cli.core.breaking_change import register_argument_deprecate + + register_argument_deprecate('test group cmd', argument='arg1', redirect='arg2') + warning = ("Argument 'arg1' has been deprecated and will be removed in next breaking change release(3.0.0). " + "Use 'arg2' instead.") + cli = DummyCli(commands_loader_cls=TestCommandsLoader) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd', '--arg1', '1', '--arg2', '2']) + self.assertEqual(f'WARNING: {warning}\n', captured_err.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', 'cmd', '--help']) + self.assertIn(warning, captured_output.getvalue().replace('\n ', ' ')) + self.assertIn('[Deprecated]', captured_output.getvalue()) + + @mock.patch('azure.cli.core.breaking_change.NEXT_BREAKING_CHANGE_RELEASE', '3.0.0') + def test_option_deprecate(self): + from contextlib import redirect_stderr, redirect_stdout + + from azure.cli.core.breaking_change import register_argument_deprecate + + register_argument_deprecate('test group cmd', argument='--arg1', redirect='--arg1-alias') + warning = ("Option '--arg1' has been deprecated and will be removed in next breaking change release(3.0.0). " + "Use '--arg1-alias' instead.") + cli = DummyCli(commands_loader_cls=TestCommandsLoader) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd', '--arg1', '1', '--arg2', '2']) + self.assertEqual(f'WARNING: {warning}\n', captured_err.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', 'cmd', '--help']) + self.assertIn(warning, captured_output.getvalue().replace('\n ', ' ')) + print(captured_output.getvalue()) + self.assertIn(' --arg1 [Deprecated]', captured_output.getvalue()) + + @mock.patch('azure.cli.core.breaking_change.NEXT_BREAKING_CHANGE_RELEASE', '3.0.0') + def test_be_required(self): + from contextlib import redirect_stderr, redirect_stdout + + from azure.cli.core.breaking_change import register_required_flag_breaking_change + + register_required_flag_breaking_change('test group cmd', arg='--arg1') + warning = "The argument '--arg1' will become required in next breaking change release(3.0.0)." + cli = DummyCli(commands_loader_cls=TestCommandsLoader) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd', '--arg1', '1', '--arg2', '2']) + self.assertEqual(f'WARNING: {warning}\n', captured_err.getvalue()) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd']) + self.assertEqual(f'WARNING: {warning}\n', captured_err.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', 'cmd', '--help']) + self.assertIn(warning, captured_output.getvalue().replace('\n ', ' ')) + self.assertIn('[Breaking Change]', captured_output.getvalue()) + + @mock.patch('azure.cli.core.breaking_change.NEXT_BREAKING_CHANGE_RELEASE', '3.0.0') + def test_default_change(self): + from contextlib import redirect_stderr, redirect_stdout + + from azure.cli.core.breaking_change import register_default_value_breaking_change + + register_default_value_breaking_change('test group cmd', arg='arg1', new_default='Default', + current_default='None') + warning = ("The default value of 'arg1' will be changed to 'Default' from 'None' " + "in next breaking change release(3.0.0).") + cli = DummyCli(commands_loader_cls=TestCommandsLoader) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd', '--arg1', '1', '--arg2', '2']) + self.assertEqual(f'WARNING: {warning}\n', captured_err.getvalue()) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd']) + self.assertEqual(f'WARNING: {warning}\n', captured_err.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', 'cmd', '--help']) + self.assertIn(warning, captured_output.getvalue().replace('\n ', ' ')) + self.assertIn('[Breaking Change]', captured_output.getvalue()) + + @mock.patch('azure.cli.core.breaking_change.NEXT_BREAKING_CHANGE_RELEASE', '3.0.0') + def test_output_change(self): + from contextlib import redirect_stderr, redirect_stdout + + from azure.cli.core.breaking_change import register_output_breaking_change + + register_output_breaking_change('test group cmd', description="The output of 'test group cmd' " + "would be changed.") + warning = ("The output will be changed in next breaking change release(3.0.0). The output of 'test group cmd' " + "would be changed.") + cli = DummyCli(commands_loader_cls=TestCommandsLoader) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd', '--arg1', '1', '--arg2', '2']) + self.assertEqual(f'WARNING: {warning}\n', captured_err.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', 'cmd', '--help']) + self.assertIn(warning, captured_output.getvalue().replace('\n ', ' ')) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', '--help']) + self.assertIn('[Breaking Change]', captured_output.getvalue()) + + @mock.patch('azure.cli.core.breaking_change.NEXT_BREAKING_CHANGE_RELEASE', '3.0.0') + def test_logic_change(self): + from contextlib import redirect_stderr, redirect_stdout + + from azure.cli.core.breaking_change import register_logic_breaking_change + + register_logic_breaking_change('test group cmd', summary="Logic Change Summary") + warning = "Logic Change Summary in next breaking change release(3.0.0)." + cli = DummyCli(commands_loader_cls=TestCommandsLoader) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd', '--arg1', '1', '--arg2', '2']) + self.assertEqual(f'WARNING: {warning}\n', captured_err.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', 'cmd', '--help']) + self.assertIn(warning, captured_output.getvalue().replace('\n ', ' ')) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', '--help']) + self.assertIn('[Breaking Change]', captured_output.getvalue()) + + @mock.patch('azure.cli.core.breaking_change.NEXT_BREAKING_CHANGE_RELEASE', '3.0.0') + def test_multi_breaking_change(self): + from contextlib import redirect_stderr, redirect_stdout + + from azure.cli.core.breaking_change import register_logic_breaking_change, register_argument_deprecate, \ + register_required_flag_breaking_change + + register_argument_deprecate('test group cmd', argument='--arg1', redirect='--arg1-alias') + warning1 = ("Option '--arg1' has been deprecated and will be removed in next breaking change release(3.0.0). " + "Use '--arg1-alias' instead.") + register_required_flag_breaking_change('test group cmd', arg='--arg1') + warning2 = "The argument '--arg1' will become required in next breaking change release(3.0.0)." + register_logic_breaking_change('test group cmd', summary="Logic Change Summary") + warning3 = "Logic Change Summary in next breaking change release(3.0.0)." + cli = DummyCli(commands_loader_cls=TestCommandsLoader) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd', '--arg1', '1', '--arg2', '2']) + self.assertIn(warning1, captured_err.getvalue()) + self.assertIn(warning2, captured_err.getvalue()) + self.assertIn(warning3, captured_err.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', 'cmd', '--help']) + self.assertIn(warning1, captured_output.getvalue().replace('\n ', ' ')) + self.assertIn(warning2, captured_output.getvalue().replace('\n ', ' ')) + self.assertIn(warning3, captured_output.getvalue().replace('\n ', ' ')) + self.assertIn('[Breaking Change]', captured_output.getvalue()) + self.assertIn('[Deprecated]', captured_output.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', '--help']) + self.assertIn('[Breaking Change]', captured_output.getvalue()) + + @mock.patch('azure.cli.core.breaking_change.NEXT_BREAKING_CHANGE_RELEASE', '3.0.0') + def test_conditional_breaking_change(self): + from contextlib import redirect_stderr, redirect_stdout + + from azure.cli.core.breaking_change import register_conditional_breaking_change, AzCLIOtherChange, \ + print_conditional_breaking_change + + warning_message = 'Test Breaking Change in Test Group' + register_conditional_breaking_change('TestConditional', AzCLIOtherChange('test group cmd', warning_message)) + cli = DummyCli(commands_loader_cls=TestCommandsLoader) + + captured_err = io.StringIO() + with redirect_stderr(captured_err): + cli.invoke(['test', 'group', 'cmd', '--arg1', '1', '--arg2', '2']) + self.assertNotIn(warning_message, captured_err.getvalue()) + + captured_output = io.StringIO() + with redirect_stdout(captured_output): + with self.assertRaises(SystemExit): + cli.invoke(['test', 'group', 'cmd', '--help']) + self.assertNotIn(warning_message, captured_err.getvalue().replace('\n ', ' ')) + self.assertNotIn('[Breaking Change]', captured_output.getvalue()) + + cli_ctx = mock.MagicMock() + cli_ctx.invocation.commands_loader.command_name = 'test group cmd' + logger = logging.getLogger('TestLogger') + with self.assertLogs(logger=logger, level='WARNING') as cm: + print_conditional_breaking_change(cli_ctx, 'TestConditional', custom_logger=logger) + self.assertListEqual([f'WARNING:TestLogger:{warning_message}'], cm.output)