Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changes/next-release/config-duplicate-profile-error.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
54 changes: 54 additions & 0 deletions awscli/botocore/configloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions awscli/botocore/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
91 changes: 91 additions & 0 deletions tests/unit/botocore/test_configloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
15 changes: 15 additions & 0 deletions tests/unit/botocore/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down