diff --git a/doc/sphinx/azhelpgen/doc_source_map.json b/doc/sphinx/azhelpgen/doc_source_map.json index 7d761b235c9..93aad4601e0 100644 --- a/doc/sphinx/azhelpgen/doc_source_map.json +++ b/doc/sphinx/azhelpgen/doc_source_map.json @@ -1,5 +1,6 @@ { "az": "src/azure-cli/azure/cli/command_modules/profile/_help.py", + "config": "src/azure-cli/azure/cli/command_modules/config/_help.py", "configure": "src/azure-cli/azure/cli/command_modules/configure/_help.py", "feedback": "src/azure-cli/azure/cli/command_modules/feedback/_help.py", "login": "src/azure-cli/azure/cli/command_modules/profile/_help.py", diff --git a/linter_exclusions.yml b/linter_exclusions.yml index dc566e917cf..a8cd7be7bfc 100644 --- a/linter_exclusions.yml +++ b/linter_exclusions.yml @@ -89,6 +89,21 @@ cloud update: cloud_name: rule_exclusions: - no_parameter_defaults_for_update_commands +config set: + parameters: + key_value: + rule_exclusions: + - no_positional_parameters +config get: + parameters: + key: + rule_exclusions: + - no_positional_parameters +config unset: + parameters: + key: + rule_exclusions: + - no_positional_parameters cosmosdb collection create: parameters: db_resource_group_name: diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index c0475a4b32d..e735b862b7a 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -895,7 +895,7 @@ def _log_response(response, **kwargs): return response -class ConfiguredDefaultSetter: +class ScopedConfig: def __init__(self, cli_config, use_local_config=None): self.use_local_config = use_local_config @@ -912,6 +912,9 @@ def __exit__(self, exc_type, exc_val, exc_tb): setattr(self.cli_config, 'use_local_config', self.original_use_local_config) +ConfiguredDefaultSetter = ScopedConfig + + def _ssl_context(): if sys.version_info < (3, 4) or (in_cloud_console() and platform.system() == 'Windows'): try: diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index eb104a71e16..069656930bc 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -56,7 +56,7 @@ 'colorama~=0.4.1', 'humanfriendly>=4.7,<9.0', 'jmespath', - 'knack==0.7.1', + 'knack==0.7.2', 'msal~=1.0.0', 'msal-extensions~=0.1.3', 'msrest>=0.4.4', diff --git a/src/azure-cli/azure/cli/command_modules/config/__init__.py b/src/azure-cli/azure/cli/command_modules/config/__init__.py new file mode 100644 index 00000000000..d06b78139f5 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/config/__init__.py @@ -0,0 +1,26 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core import AzCommandsLoader + +import azure.cli.command_modules.config._help # pylint: disable=unused-import + + +class ConfigCommandsLoader(AzCommandsLoader): + + def __init__(self, cli_ctx=None): + super(ConfigCommandsLoader, self).__init__(cli_ctx=cli_ctx) + + def load_command_table(self, args): + from azure.cli.command_modules.config.commands import load_command_table + load_command_table(self, args) + return self.command_table + + def load_arguments(self, command): + from azure.cli.command_modules.config._params import load_arguments + load_arguments(self, command) + + +COMMAND_LOADER_CLS = ConfigCommandsLoader diff --git a/src/azure-cli/azure/cli/command_modules/config/_help.py b/src/azure-cli/azure/cli/command_modules/config/_help.py new file mode 100644 index 00000000000..ce17a912229 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/config/_help.py @@ -0,0 +1,55 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.help_files import helps # pylint: disable=unused-import +# pylint: disable=line-too-long, too-many-lines + +helps['config'] = """ +type: group +short-summary: Manage Azure CLI configuration. +""" + +helps['config set'] = """ +type: command +short-summary: Set a configuration. +long-summary: | + For available configuration options, see https://docs.microsoft.com/en-us/cli/azure/azure-cli-configuration. + By default without specifying --local, the configuration will be saved to `~/.azure/config`. +examples: + - name: Disable color with `core.no_color`. + text: az config set core.no_color=true + - name: Hide warnings and only show errors with `core.only_show_errors`. + text: az config set core.only_show_errors=true + - name: Turn on client-side telemetry. + text: az config set core.collect_telemetry=true + - name: Turn on file logging and set its location. + text: |- + az config set logging.enable_log_file=true + az config set logging.log_dir=~/az-logs + - name: Set the default location to `westus2` and default resource group to `myRG`. + text: az config set defaults.location=westus2 defaults.group=MyResourceGroup + - name: Set the default resource group to `myRG` on a local scope. + text: az config set defaults.group=myRG --local +""" + +helps['config get'] = """ +type: command +short-summary: Get a configuration. +examples: + - name: Get all configurations. + text: az config get + - name: Get configurations in `core` section. + text: az config get core + - name: Get the configuration of key `core.no_color`. + text: az config get core.no_color +""" + +helps['config unset'] = """ +type: command +short-summary: Unset a configuration. +examples: + - name: Unset the configuration of key `core.no_color`. + text: az config unset core.no_color +""" diff --git a/src/azure-cli/azure/cli/command_modules/config/_params.py b/src/azure-cli/azure/cli/command_modules/config/_params.py new file mode 100644 index 00000000000..9f35fca56a6 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/config/_params.py @@ -0,0 +1,30 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=line-too-long + + +def load_arguments(self, _): + with self.argument_context('config') as c: + c.ignore('_subscription') # ignore the global subscription param + + with self.argument_context('config set') as c: + c.positional('key_value', nargs='+', help="Space-separated configurations in the form of
.=.") + c.argument('local', action='store_true', help='Set as a local configuration in the working directory.') + + with self.argument_context('config get') as c: + c.positional('key', nargs='?', help='The configuration to get. ' + 'If not provided, all sections and configurations will be listed. ' + 'If `section` is provided, all configurations under the specified section will be listed. ' + 'If `
.` is provided, only the corresponding configuration is shown.') + c.argument('local', action='store_true', + help='Include local configuration. Scan from the working directory up to the root drive, then the global configuration ' + 'and return the first occurrence.') + + with self.argument_context('config unset') as c: + c.positional('key', nargs='+', help='The configuration to unset, in the form of
..') + c.argument('local', action='store_true', + help='Include local configuration. Scan from the working directory up to the root drive, then the global configuration ' + 'and unset the first occurrence.') diff --git a/src/azure-cli/azure/cli/command_modules/config/commands.py b/src/azure-cli/azure/cli/command_modules/config/commands.py new file mode 100644 index 00000000000..094ce68e58d --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/config/commands.py @@ -0,0 +1,16 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.commands import CliCommandType + + +def load_command_table(self, _): + + config_custom = CliCommandType(operations_tmpl='azure.cli.command_modules.config.custom#{}') + + with self.command_group('config', config_custom, is_experimental=True) as g: + g.command('set', 'config_set') + g.command('get', 'config_get') + g.command('unset', 'config_unset') diff --git a/src/azure-cli/azure/cli/command_modules/config/custom.py b/src/azure-cli/azure/cli/command_modules/config/custom.py new file mode 100644 index 00000000000..bab3eb02b30 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/config/custom.py @@ -0,0 +1,88 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.log import get_logger +from knack.util import CLIError + +from azure.cli.core.util import ScopedConfig + +logger = get_logger(__name__) + + +def _normalize_config_value(value): + if value: + value = '' if value in ["''", '""'] else value + return value + + +def config_set(cmd, key_value=None, local=False): + if key_value: + with ScopedConfig(cmd.cli_ctx.config, local): + for kv in key_value: + # core.no_color=true + parts = kv.split('=', 1) + if len(parts) == 1: + raise CLIError('usage error: [section].[name]=[value] ...') + key = parts[0] + value = parts[1] + + # core.no_color + parts = key.split('.', 1) + if len(parts) == 1: + raise CLIError('usage error: [section].[name]=[value] ...') + section = parts[0] + name = parts[1] + + cmd.cli_ctx.config.set_value(section, name, _normalize_config_value(value)) + + +def config_get(cmd, key=None, local=False): + # No arg. List all sections and all items + if not key: + with ScopedConfig(cmd.cli_ctx.config, local): + sections = cmd.cli_ctx.config.sections() + result = {} + for section in sections: + items = cmd.cli_ctx.config.items(section) + result[section] = items + return result + + parts = key.split('.', 1) + if len(parts) == 1: + # Only section is provided + section = key + name = None + else: + # section.name + section = parts[0] + name = parts[1] + + with ScopedConfig(cmd.cli_ctx.config, local): + items = cmd.cli_ctx.config.items(section) + + if not name: + # Only section + return items + + # section.option + try: + return next(x for x in items if x['name'] == name) + except StopIteration: + raise CLIError("Configuration '{}' is not set.".format(key)) + + +def config_unset(cmd, key=None, local=False): + for k in key: + # section.name + parts = k.split('.', 1) + + if len(parts) == 1: + raise CLIError("usage error: [section].[name]") + + section = parts[0] + name = parts[1] + + with ScopedConfig(cmd.cli_ctx.config, local): + cmd.cli_ctx.config.remove_option(section, name) diff --git a/src/azure-cli/azure/cli/command_modules/config/tests/__init__.py b/src/azure-cli/azure/cli/command_modules/config/tests/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/config/tests/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/src/azure-cli/azure/cli/command_modules/config/tests/latest/__init__.py b/src/azure-cli/azure/cli/command_modules/config/tests/latest/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/config/tests/latest/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/src/azure-cli/azure/cli/command_modules/config/tests/latest/test_config.py b/src/azure-cli/azure/cli/command_modules/config/tests/latest/test_config.py new file mode 100644 index 00000000000..e1fb7395d28 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/config/tests/latest/test_config.py @@ -0,0 +1,81 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +import os +import tempfile +import unittest +from unittest.mock import MagicMock + +from azure.cli.testsdk import ScenarioTest, LocalContextScenarioTest +from knack.util import CLIError + + +class ConfigTest(ScenarioTest): + + def test_config(self): + + # [test_section1] + # test_option1 = test_value1 + # + # [test_section2] + # test_option21 = test_value21 + # test_option22 = test_value22 + + # C:\Users\{username}\AppData\Local\Temp + tempdir = tempfile.gettempdir() + original_path = os.getcwd() + os.chdir(tempdir) + print("Using temp dir: {}".format(tempdir)) + + global_test_args = {"source": os.path.expanduser(os.path.join('~', '.azure', 'config')), "flag": ""} + local_test_args = {"source": os.path.join(tempdir, '.azure', 'config'), "flag": " --local"} + + for args in (global_test_args, local_test_args): + test_option1_expected = {'name': 'test_option1', 'source': args["source"], 'value': 'test_value1'} + test_option21_expected = {'name': 'test_option21', 'source': args["source"], 'value': 'test_value21'} + test_option22_expected = {'name': 'test_option22', 'source': args["source"], 'value': 'test_value22'} + + test_section1_expected = [test_option1_expected] + test_section2_expected = [test_option21_expected, test_option22_expected] + + # 1. set + # Test setting one option + self.cmd('config set test_section1.test_option1=test_value1' + args['flag']) + # Test setting multiple options + self.cmd('config set test_section2.test_option21=test_value21 test_section2.test_option22=test_value22' + args['flag']) + + # 2. get + # 2.1 Test get all sections + output = self.cmd('config get' + args['flag']).get_output_in_json() + self.assertListEqual(output['test_section1'], test_section1_expected) + self.assertListEqual(output['test_section2'], test_section2_expected) + + # 2.2 Test get one section + output = self.cmd('config get test_section1' + args['flag']).get_output_in_json() + self.assertListEqual(output, test_section1_expected) + output = self.cmd('config get test_section2' + args['flag']).get_output_in_json() + self.assertListEqual(output, test_section2_expected) + + # 2.3 Test get one item + output = self.cmd('config get test_section1.test_option1' + args['flag']).get_output_in_json() + self.assertDictEqual(output, test_option1_expected) + output = self.cmd('config get test_section2.test_option21' + args['flag']).get_output_in_json() + self.assertDictEqual(output, test_option21_expected) + output = self.cmd('config get test_section2.test_option22' + args['flag']).get_output_in_json() + self.assertDictEqual(output, test_option22_expected) + + with self.assertRaises(CLIError): + self.cmd('config get test_section1.test_option22' + args['flag']) + + # 3. unset + # Test unsetting one option + self.cmd('config unset test_section1.test_option1' + args['flag']) + # Test unsetting multiple options + self.cmd('config unset test_section2.test_option21 test_section2.test_option22' + args['flag']) + + os.chdir(original_path) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 900bf69ba38..16ecb6e4489 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -99,7 +99,7 @@ isodate==0.6.0 Jinja2==2.10.1 jmespath==0.9.5 jsmin==2.2.2 -knack==0.7.1 +knack==0.7.2 MarkupSafe==1.1.1 mock==4.0.2 msrestazure==0.6.3 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index ea3bf9bd2c4..05ec99498e2 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -99,7 +99,7 @@ isodate==0.6.0 Jinja2==2.10.1 jmespath==0.9.5 jsmin==2.2.2 -knack==0.7.1 +knack==0.7.2 MarkupSafe==1.1.1 mock==4.0.2 msrest==0.6.9 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 495e03dfa88..564f1a65515 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -97,7 +97,7 @@ isodate==0.6.0 Jinja2==2.10.1 jmespath==0.9.5 jsmin==2.2.2 -knack==0.7.1 +knack==0.7.2 MarkupSafe==1.1.1 mock==4.0.2 msrest==0.6.9