From 8b6a2e1959f1c179c56b52f091b40c667cb55a2b Mon Sep 17 00:00:00 2001 From: Kushagra Date: Thu, 12 Nov 2026 01:52:06 +0530 Subject: [PATCH] Improve error message for duplicate profile sections in config --- .../config-duplicate-profile-error.json | 7 ++ awscli/botocore/configloader.py | 54 +++++++++++ awscli/botocore/exceptions.py | 5 + tests/unit/botocore/test_configloader.py | 91 +++++++++++++++++++ tests/unit/botocore/test_exceptions.py | 15 +++ 5 files changed, 172 insertions(+) create mode 100644 .changes/next-release/config-duplicate-profile-error.json diff --git a/.changes/next-release/config-duplicate-profile-error.json b/.changes/next-release/config-duplicate-profile-error.json new file mode 100644 index 000000000000..ec53dae37f94 --- /dev/null +++ b/.changes/next-release/config-duplicate-profile-error.json @@ -0,0 +1,7 @@ +[ + { + "type": "bugfix", + "category": "config", + "description": "Improved error message when config file contains duplicate profile sections to include the profile name and file path" + } +] diff --git a/awscli/botocore/configloader.py b/awscli/botocore/configloader.py index e7bbf68d78d4..9ef388e15c25 100644 --- a/awscli/botocore/configloader.py +++ b/awscli/botocore/configloader.py @@ -146,6 +146,14 @@ def raw_config_parse(config_filename, parse_subsections=True): cp = configparser.RawConfigParser() try: cp.read([path]) + except configparser.DuplicateSectionError as e: + message = _get_duplicate_section_error_message( + path, e.section, e.lineno + ) + raise botocore.exceptions.ConfigParseError( + path=_unicode_path(path), + message=message, + ) except (configparser.Error, UnicodeDecodeError): raise botocore.exceptions.ConfigParseError( path=_unicode_path(path) @@ -200,6 +208,52 @@ def _parse_nested(config_value): return parsed +def _find_first_section_line(path, section): + try: + with open(path, 'r', encoding='utf-8-sig', errors='replace') as f: + for lineno, line in enumerate(f, start=1): + clean_line = line.strip() + if not clean_line or clean_line.startswith(('#', ';')): + continue + match = configparser.RawConfigParser.SECTCRE.match(clean_line) + if match: + header = match.group('header') + if header == section or header.strip() == section.strip(): + return lineno + except Exception: + pass + return None + + +def _get_duplicate_section_error_message(path, section, dup_lineno): + parts = section.split(None, 1) + if len(parts) == 2 and parts[0] == 'profile': + target_type = 'profile' + target_name = parts[1].strip('"\'') + elif section == 'default': + target_type = 'profile' + target_name = 'default' + else: + target_type = 'section' + target_name = section + + first_lineno = _find_first_section_line(path, section) + + if first_lineno and dup_lineno and first_lineno != dup_lineno: + line_info = ( + f" (first seen on line {first_lineno}, " + f"duplicated on line {dup_lineno})" + ) + elif dup_lineno: + line_info = f" (duplicated on line {dup_lineno})" + elif first_lineno: + line_info = f" (line {first_lineno})" + else: + line_info = "" + + return f"Found duplicate {target_type} '{target_name}'{line_info}" + + def _parse_section(key, values): result = {} try: diff --git a/awscli/botocore/exceptions.py b/awscli/botocore/exceptions.py index 83d409867bcc..9c5b0dba18a8 100644 --- a/awscli/botocore/exceptions.py +++ b/awscli/botocore/exceptions.py @@ -299,6 +299,11 @@ class ConfigParseError(BotoCoreError): fmt = 'Unable to parse config file: {path}' + def __init__(self, **kwargs): + if 'message' in kwargs: + self.fmt = 'Unable to parse config file: {path} - {message}' + super().__init__(**kwargs) + class ConfigNotFound(BotoCoreError): """ diff --git a/tests/unit/botocore/test_configloader.py b/tests/unit/botocore/test_configloader.py index 0238592da7fb..d47eb1a1c8e2 100644 --- a/tests/unit/botocore/test_configloader.py +++ b/tests/unit/botocore/test_configloader.py @@ -207,6 +207,97 @@ def test_services_config(self): 'https://localhost:8888/', ) + def test_duplicate_profile_error_message(self): + contents = ( + '[default]\n' + 'region = us-west-2\n\n' + '[profile xyz]\n' + 'aws_access_key_id = foo\n' + 'aws_secret_access_key = bar\n\n' + '[profile other]\n' + 'region = eu-west-1\n\n' + '[profile xyz]\n' + 'aws_access_key_id = baz\n' + ) + full_path = os.path.join(self.tempdir, 'aws_config_dup_profile') + with open(full_path, 'w') as f: + f.write(contents) + + with self.assertRaises(botocore.exceptions.ConfigParseError) as cm: + raw_config_parse(full_path) + expected_msg = ( + f"Unable to parse config file: {full_path} - " + "Found duplicate profile 'xyz' " + "(first seen on line 4, duplicated on line 11)" + ) + self.assertEqual(str(cm.exception), expected_msg) + + # Also verify load_config raises the same error + with self.assertRaises(botocore.exceptions.ConfigParseError) as cm: + load_config(full_path) + self.assertEqual(str(cm.exception), expected_msg) + + def test_duplicate_profile_with_quotes_error_message(self): + contents = ( + '[profile "personal"]\n' + 'aws_access_key_id = foo\n' + 'aws_secret_access_key = bar\n\n' + '[profile "personal"]\n' + 'aws_access_key_id = baz\n' + ) + full_path = os.path.join(self.tempdir, 'aws_config_dup_quotes') + with open(full_path, 'w') as f: + f.write(contents) + + with self.assertRaises(botocore.exceptions.ConfigParseError) as cm: + raw_config_parse(full_path) + expected_msg = ( + f"Unable to parse config file: {full_path} - " + "Found duplicate profile 'personal' " + "(first seen on line 1, duplicated on line 5)" + ) + self.assertEqual(str(cm.exception), expected_msg) + + def test_duplicate_default_profile_error_message(self): + contents = ( + '[default]\n' + 'region = us-west-2\n\n' + '[default]\n' + 'region = us-east-1\n' + ) + full_path = os.path.join(self.tempdir, 'aws_config_dup_default') + with open(full_path, 'w') as f: + f.write(contents) + + with self.assertRaises(botocore.exceptions.ConfigParseError) as cm: + raw_config_parse(full_path) + expected_msg = ( + f"Unable to parse config file: {full_path} - " + "Found duplicate profile 'default' " + "(first seen on line 1, duplicated on line 4)" + ) + self.assertEqual(str(cm.exception), expected_msg) + + def test_duplicate_section_error_message(self): + contents = ( + '[sso-session my-session]\n' + 'sso_region = us-west-2\n\n' + '[sso-session my-session]\n' + 'sso_region = us-east-1\n' + ) + full_path = os.path.join(self.tempdir, 'aws_config_dup_section') + with open(full_path, 'w') as f: + f.write(contents) + + with self.assertRaises(botocore.exceptions.ConfigParseError) as cm: + raw_config_parse(full_path) + expected_msg = ( + f"Unable to parse config file: {full_path} - " + "Found duplicate section 'sso-session my-session' " + "(first seen on line 1, duplicated on line 4)" + ) + self.assertEqual(str(cm.exception), expected_msg) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/botocore/test_exceptions.py b/tests/unit/botocore/test_exceptions.py index 517e8cf75826..ba74b3bbabe5 100644 --- a/tests/unit/botocore/test_exceptions.py +++ b/tests/unit/botocore/test_exceptions.py @@ -98,6 +98,21 @@ def test_single_kwarg_botocore_error(self): self.assertEqual(str(unpickled_exception), str(exception)) self.assertEqual(unpickled_exception.kwargs, exception.kwargs) + def test_config_parse_error(self): + exception = botocore.exceptions.ConfigParseError( + path='/path/to/config', + message=( + "Found duplicate profile 'xyz' " + "(first seen on line 5, duplicated on line 20)" + ), + ) + unpickled_exception = pickle.loads(pickle.dumps(exception)) + self.assertIsInstance( + unpickled_exception, botocore.exceptions.ConfigParseError + ) + self.assertEqual(str(unpickled_exception), str(exception)) + self.assertEqual(unpickled_exception.kwargs, exception.kwargs) + def test_multiple_kwarg_botocore_error(self): exception = botocore.exceptions.UnknownServiceError( service_name='myservice', known_service_names=['s3']