From a0fa3ec55c531b12d7de947bfedd8ad47f0a89af Mon Sep 17 00:00:00 2001 From: James Saryerwinnie <js@jamesls.com> Date: Mon, 25 Nov 2013 16:33:00 -0800 Subject: [PATCH 1/4] Remove unused function --- awscli/customizations/configure.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/awscli/customizations/configure.py b/awscli/customizations/configure.py index 3e97338308ca..e4b55c32c13a 100644 --- a/awscli/customizations/configure.py +++ b/awscli/customizations/configure.py @@ -33,9 +33,6 @@ def register_configure_cmd(cli): cli.register('building-command-table.main', ConfigureCommand.add_command) -def add_configure_cmd(command_table, session, **kwargs): - command_table['configure'] = ConfigureCommand(session) - class SectionNotFoundError(Exception): pass From a54f8861c93dd0b0ad4e9b14cb20434d283c6e01 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie <js@jamesls.com> Date: Mon, 25 Nov 2013 18:17:21 -0800 Subject: [PATCH 2/4] Add an 'aws configure list' command This will show you where all the config values are coming from. As part of this change, I also updated the commands.py module to support subcommands. This makes it easy to create a top level command that supports subcommands in a declarative manner. You can now run: * `aws configure` * `aws configure list` As an example, you'll see output like this: Name Value Type Location ---- ----- ---- -------- profile <not set> None None access_key ****************ABCD config_file ~/.aws/config secret_key ****************ABCD config_file ~/.aws/config region us-west-2 config_file ~/.aws/config --- awscli/argparser.py | 14 ++- awscli/customizations/commands.py | 35 +++++- awscli/customizations/configure.py | 110 +++++++++++++++++-- tests/unit/customizations/test_configure.py | 115 +++++++++++++++++++- 4 files changed, 257 insertions(+), 17 deletions(-) diff --git a/awscli/argparser.py b/awscli/argparser.py index cb2a09562411..751a1404bdc3 100644 --- a/awscli/argparser.py +++ b/awscli/argparser.py @@ -94,18 +94,26 @@ class ArgTableArgParser(CLIArgParser): """CLI arg parser based on an argument table.""" Usage = ("aws [options] <command> <subcommand> [parameters]") - def __init__(self, argument_table): + def __init__(self, argument_table, command_table=None): + # command_table is an optional subcommand_table. If it's passed + # in, then we'l update the argparse to parse a 'subcommand' argument + # and populate the choices field with the command table keys. super(ArgTableArgParser, self).__init__( formatter_class=self.Formatter, add_help=False, usage=self.Usage, conflict_handler='resolve') - self._build(argument_table) + if command_table is None: + command_table = {} + self._build(argument_table, command_table) - def _build(self, argument_table): + def _build(self, argument_table, command_table): for arg_name in argument_table: argument = argument_table[arg_name] argument.add_to_parser(self) + if command_table: + self.add_argument('subcommand', choices=list(command_table.keys()), + nargs='?') def parse_known_args(self, args, namespace=None): if len(args) == 1 and args[0] == 'help': diff --git a/awscli/customizations/commands.py b/awscli/customizations/commands.py index 3de0ffffb28f..7517c3f5b935 100644 --- a/awscli/customizations/commands.py +++ b/awscli/customizations/commands.py @@ -1,5 +1,7 @@ import bcdoc.docevents +from botocore.compat import OrderedDict + from awscli.clidocs import CLIDocumentEventHandler from awscli.argparser import ArgTableArgParser from awscli.clidriver import CLICommand @@ -43,6 +45,15 @@ class BasicCommand(CLICommand): # 'action': 'store', 'choices': ['a', 'b', 'c']}, # ] ARG_TABLE = [] + # If you want the command to have subcommands, you can provide a list of + # dicts. We use a list here because we want to allow a user to provide + # the order they want to use for subcommands. + # SUBCOMMANDS = [ + # {'name': 'subcommand1', 'command_class': SubcommandClass}, + # {'name': 'subcommand2', 'command_class': SubcommandClass2}, + # ] + # The command_class must subclass from ``BasicCommand``. + SUBCOMMANDS = [] # At this point, the only other thing you have to implement is a _run_main # method (see the method for more information). @@ -54,12 +65,17 @@ def __call__(self, args, parsed_globals): # args is the remaining unparsed args. # We might be able to parse these args so we need to create # an arg parser and parse them. - parser = ArgTableArgParser(self.arg_table) - parsed_args = parser.parse_args(args) + subcommand_table = self._build_subcommand_table() + parser = ArgTableArgParser(self.arg_table, subcommand_table) + parsed_args, remaining = parser.parse_known_args(args) if hasattr(parsed_args, 'help'): self._display_help(parsed_args, parsed_globals) - else: + elif getattr(parsed_args, 'subcommand', None) is None: + # No subcommand was specified was call the main + # function for this top level command. self._run_main(parsed_args, parsed_globals) + else: + subcommand_table[parsed_args.subcommand](remaining, parsed_globals) def _run_main(self, parsed_args, parsed_globals): # Subclasses should implement this method. @@ -70,7 +86,18 @@ def _run_main(self, parsed_args, parsed_globals): # provided as the 'dest' key. Otherwise they default to the # 'name' key. For example: ARG_TABLE[0] = {"name": "foo-arg", ...} # can be accessed by ``parsed_args.foo_arg``. - raise NotImlementedError("_run_main") + raise NotImplementedError("_run_main") + + def _build_subcommand_table(self): + subcommand_table = OrderedDict() + for subcommand in self.SUBCOMMANDS: + subcommand_name = subcommand['name'] + subcommand_class = subcommand['command_class'] + subcommand_table[subcommand_name] = subcommand_class(self._session) + self._session.emit('building-command-table.%s' % self.NAME, + command_table=subcommand_table, + session=self._session) + return subcommand_table def _display_help(self, parsed_args, parsed_globals): help_command = self.create_help_command() diff --git a/awscli/customizations/configure.py b/awscli/customizations/configure.py index e4b55c32c13a..c6f78790db7f 100644 --- a/awscli/customizations/configure.py +++ b/awscli/customizations/configure.py @@ -12,6 +12,7 @@ # language governing permissions and limitations under the License. import os import re +import sys import logging import six @@ -27,6 +28,7 @@ logger = logging.getLogger(__name__) +NOT_SET = '<not set>' def register_configure_cmd(cli): @@ -34,14 +36,33 @@ def register_configure_cmd(cli): ConfigureCommand.add_command) +class ConfigValue(object): + def __init__(self, value, config_type, config_variable): + self.value = value + self.config_type = config_type + self.config_variable = config_variable + + def mask_value(self): + if self.value is NOT_SET: + return + self.value = _mask_value(self.value) + + class SectionNotFoundError(Exception): pass +def _mask_value(current_value): + if current_value is None: + return 'None' + else: + return ('*' * 16) + current_value[-4:] + + class InteractivePrompter(object): def get_value(self, current_value, config_name, prompt_text=''): if config_name in ('aws_access_key_id', 'aws_secret_access_key'): - current_value = self._mask_value(current_value) + current_value = _mask_value(current_value) response = raw_input("%s [%s]: " % (prompt_text, current_value)) if not response: # If the user hits enter, we return a value of None @@ -50,12 +71,6 @@ def get_value(self, current_value, config_name, prompt_text=''): response = None return response - def _mask_value(self, current_value): - if current_value is None: - return 'None' - else: - return ('*' * 16) + current_value[-4:] - class ConfigFileWriter(object): SECTION_REGEX = re.compile(r'\[(?P<header>[^]]+)\]') @@ -154,6 +169,84 @@ def _matches_section(self, match, section_name): return unquoted_match +class ConfigureListCommand(BasicCommand): + NAME = 'list' + + def __init__(self, session, stream=sys.stdout): + super(ConfigureListCommand, self).__init__(session) + self._stream = stream + + def _run_main(self, args, parsed_globals): + self._display_config_value(ConfigValue('Value', 'Type', 'Location'), + 'Name') + self._display_config_value(ConfigValue('-----', '----', '--------'), + '----') + + if self._session.profile is not None: + profile = ConfigValue(self._session.profile, 'manual', + '--profile') + else: + profile = self._lookup_config('profile') + self._display_config_value(profile, 'profile') + + access_key, secret_key = self._lookup_credentials() + self._display_config_value(access_key, 'access_key') + self._display_config_value(secret_key, 'secret_key') + + region = self._lookup_config('region') + self._display_config_value(region, 'region') + + def _display_config_value(self, config_value, config_name): + self._stream.write('%10s %24s %16s %s\n' % ( + config_name, config_value.value, config_value.config_type, + config_value.config_variable)) + + def _lookup_credentials(self): + # First try it with _lookup_config. It's possible + # that we don't find credentials this way (for example, + # if we're using an IAM role). + access_key = self._lookup_config('access_key') + if access_key.value is not NOT_SET: + secret_key = self._lookup_config('secret_key') + access_key.mask_value() + secret_key.mask_value() + return access_key, secret_key + else: + # Otherwise we can try to use get_credentials(). + # This includes a few more lookup locations + # (IAM roles, some of the legacy configs, etc.) + credentials = self._session.get_credentials() + if credentials is None: + no_config = ConfigValue(NOT_SET, None, None) + return no_config, no_config + else: + # For the ConfigValue, we don't track down the + # config_variable because that info is not + # visible from botocore.credentials. I think + # the credentials.method is sufficient to show + # where the credentials are coming from. + access_key = ConfigValue(credentials.access_key, + credentials.method, '') + secret_key = ConfigValue(credentials.secret_key, + credentials.method, '') + access_key.mask_value() + secret_key.mask_value() + return access_key, secret_key + + def _lookup_config(self, name): + # First try to look up the variable in the env. + value = self._session.get_variable(name, methods=('env',)) + if value is not None: + return ConfigValue(value, 'env', self._session.env_vars[name][1]) + # Then try to look up the variable in the config file. + value = self._session.get_variable(name, methods=('config',)) + if value is not None: + return ConfigValue(value, 'config_file', + self._session.get_variable('config_file')) + else: + return ConfigValue(NOT_SET, None, None) + + class ConfigureCommand(BasicCommand): NAME = 'configure' DESCRIPTION = ( @@ -189,6 +282,9 @@ class ConfigureCommand(BasicCommand): ' Default region name [us-west-1]: us-west-2\n' ' Default output format [None]:\n' ) + SUBCOMMANDS = [ + {'name': 'list', 'command_class': ConfigureListCommand} + ] # If you want to add new values to prompt, update this list here. VALUES_TO_PROMPT = [ diff --git a/tests/unit/customizations/test_configure.py b/tests/unit/customizations/test_configure.py index 9a4d4201d208..2c52adf0b407 100644 --- a/tests/unit/customizations/test_configure.py +++ b/tests/unit/customizations/test_configure.py @@ -18,6 +18,7 @@ from tests import BaseAWSHelpOutputTest import mock +from six import StringIO from botocore.exceptions import ProfileNotFound from awscli.customizations import configure @@ -46,10 +47,23 @@ def get_value(self, current_value, config_name, prompt_text=''): class FakeSession(object): - def __init__(self, variables, profile_does_not_exist=False): - self.variables = variables + def __init__(self, all_variables, profile_does_not_exist=False, + config_file_vars=None, environment_vars=None, + credentials=None): + self.variables = all_variables self.profile_does_not_exist = profile_does_not_exist self.config = {} + if config_file_vars is None: + config_file_vars = {} + self.config_file_vars = config_file_vars + if environment_vars is None: + environment_vars = {} + self.environment_vars = environment_vars + self._credentials = credentials + self.profile = None + + def get_credentials(self): + return self._credentials def get_config(self): if self.profile_does_not_exist: @@ -59,7 +73,16 @@ def get_config(self): def get_variable(self, name, methods=None): if self.profile_does_not_exist and not name == 'config_file': raise ProfileNotFound(profile='foo') - return self.variables.get(name) + if methods is not None: + if 'env' in methods: + return self.environment_vars.get(name) + elif 'config' in methods: + return self.config_file_vars.get(name) + else: + return self.variables.get(name) + + def emit(self, event_name, **kwargs): + pass class TestConfigureCommand(unittest.TestCase): @@ -422,3 +445,89 @@ def test_profile_with_multiple_spaces(self): 'foo = newvalue\n' 'bar = 1\n' ) + + +class TestConfigureListCommand(unittest.TestCase): + def setUp(self): + pass + + def test_configure_list_command_nothing_set(self): + # Test the case where the user only wants to change a single_value. + session = FakeSession(all_variables={'config_file': '/config/location'}) + stream = StringIO() + self.configure_list = configure.ConfigureListCommand(session, stream) + self.configure_list(args=[], parsed_globals=None) + rendered = stream.getvalue() + self.assertRegexpMatches(rendered, 'profile\s+<not set>') + self.assertRegexpMatches(rendered, 'access_key\s+<not set>') + self.assertRegexpMatches(rendered, 'secret_key\s+<not set>') + self.assertRegexpMatches(rendered, 'region\s+<not set>') + + def test_configure_from_env(self): + env_vars = { + 'profile': 'myprofilename' + } + session = FakeSession( + all_variables={'config_file': '/config/location'}, + environment_vars=env_vars) + session.env_vars = {'profile': (None, "PROFILE_ENV_VAR")} + stream = StringIO() + self.configure_list = configure.ConfigureListCommand(session, stream) + self.configure_list(args=[], parsed_globals=None) + rendered = stream.getvalue() + self.assertRegexpMatches( + rendered, 'profile\s+myprofilename\s+env\s+PROFILE_ENV_VAR') + + def test_configure_from_config_file(self): + config_file_vars = { + 'region': 'us-west-2' + } + session = FakeSession( + all_variables={'config_file': '/config/location'}, + config_file_vars=config_file_vars) + session.env_vars = {'region': ('region', "AWS_REGION")} + stream = StringIO() + self.configure_list = configure.ConfigureListCommand(session, stream) + self.configure_list(args=[], parsed_globals=None) + rendered = stream.getvalue() + self.assertRegexpMatches( + rendered, 'region\s+us-west-2\s+config_file\s+/config/location') + + def test_configure_from_multiple_sources(self): + # Here the profile is from an env var, the + # region is from the config file, and the credentials + # are from an iam-role. + env_vars = { + 'profile': 'myprofilename' + } + config_file_vars = { + 'region': 'us-west-2' + } + credentials = mock.Mock() + credentials.access_key = 'access_key' + credentials.secret_key = 'secret_key' + credentials.method = 'iam-role' + session = FakeSession( + all_variables={'config_file': '/config/location'}, + environment_vars=env_vars, + config_file_vars=config_file_vars, + credentials=credentials) + session.env_vars = {'region': ('region', 'AWS_REGION'), + 'profile': ('profile', 'AWS_DEFAULT_PROFILE')} + stream = StringIO() + self.configure_list = configure.ConfigureListCommand(session, stream) + self.configure_list(args=[], parsed_globals=None) + rendered = stream.getvalue() + # The profile came from an env var. + self.assertRegexpMatches( + rendered, 'profile\s+myprofilename\s+env\s+AWS_DEFAULT_PROFILE') + # The region came from the config file. + self.assertRegexpMatches( + rendered, 'region\s+us-west-2\s+config_file\s+/config/location') + # The credentials came from an IAM role. Note how we're + # also checking that the access_key/secret_key are masked + # with '*' chars except for the last 4 chars. + self.assertRegexpMatches( + rendered, r'access_key\s+\*+_key\s+iam-role') + self.assertRegexpMatches( + rendered, r'secret_key\s+\*+_key\s+iam-role') From 83574d2369a9a9c2cd9643587c58ca56c95fa978 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie <js@jamesls.com> Date: Tue, 26 Nov 2013 10:39:11 -0800 Subject: [PATCH 3/4] Add docs for the aws configure list command --- awscli/customizations/configure.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/awscli/customizations/configure.py b/awscli/customizations/configure.py index c6f78790db7f..812b09ad1956 100644 --- a/awscli/customizations/configure.py +++ b/awscli/customizations/configure.py @@ -171,6 +171,30 @@ def _matches_section(self, match, section_name): class ConfigureListCommand(BasicCommand): NAME = 'list' + DESCRIPTION = ( + 'List the AWS CLI configuration data. This command will ' + 'show you the current configuration data. For each configuration ' + 'item, it will show you the value, where the configuration value ' + 'was retrieved, and the configuration variable name. For example, ' + 'if you provide the AWS region in an environment variable, this ' + 'command will show you the name of the region you\'ve configured, ' + 'it will tell you that this value came from an environment ' + 'variable, and it will tell you the name of the environment ' + 'variable.\n' + ) + SYNOPSIS = ('aws configure list [--profile profile-name]') + EXAMPLES = ( + 'To show your current configuration values::\n' + '\n' + ' $ aws configure list\n' + ' Name Value Type Location\n' + ' ---- ----- ---- --------\n' + ' profile <not set> None None\n' + ' access_key ****************ABCD config_file ~/.aws/config\n' + ' secret_key ****************ABCD config_file ~/.aws/config\n' + ' region us-west-2 env AWS_DEFAULT_REGION\n' + '\n' + ) def __init__(self, session, stream=sys.stdout): super(ConfigureListCommand, self).__init__(session) From 89c1551d6598993b2980160ec1d3311ae84a107c Mon Sep 17 00:00:00 2001 From: James Saryerwinnie <js@jamesls.com> Date: Tue, 26 Nov 2013 13:17:34 -0800 Subject: [PATCH 4/4] Correct grammar/typos in comments --- awscli/argparser.py | 2 +- awscli/customizations/commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/awscli/argparser.py b/awscli/argparser.py index 751a1404bdc3..389d0b1d5c16 100644 --- a/awscli/argparser.py +++ b/awscli/argparser.py @@ -96,7 +96,7 @@ class ArgTableArgParser(CLIArgParser): def __init__(self, argument_table, command_table=None): # command_table is an optional subcommand_table. If it's passed - # in, then we'l update the argparse to parse a 'subcommand' argument + # in, then we'll update the argparse to parse a 'subcommand' argument # and populate the choices field with the command table keys. super(ArgTableArgParser, self).__init__( formatter_class=self.Formatter, diff --git a/awscli/customizations/commands.py b/awscli/customizations/commands.py index 7517c3f5b935..afddc25f1beb 100644 --- a/awscli/customizations/commands.py +++ b/awscli/customizations/commands.py @@ -71,7 +71,7 @@ def __call__(self, args, parsed_globals): if hasattr(parsed_args, 'help'): self._display_help(parsed_args, parsed_globals) elif getattr(parsed_args, 'subcommand', None) is None: - # No subcommand was specified was call the main + # No subcommand was specified so call the main # function for this top level command. self._run_main(parsed_args, parsed_globals) else: