diff --git a/Lib/configparser.py b/Lib/configparser.py index 88015ef6086569..f1b37a717df996 100644 --- a/Lib/configparser.py +++ b/Lib/configparser.py @@ -1229,8 +1229,10 @@ def _convert_to_boolean(self, value): return self.BOOLEAN_STATES[value.lower()] def _validate_key_contents(self, key): - """Raises an InvalidWriteError for any keys containing - delimiters or that begins with the section header pattern""" + """Raise InvalidWriteError for keys that cannot be read back.""" + if key != key.strip(): + raise InvalidWriteError( + f"Cannot write key {key!r}; leading or trailing whitespace") if re.match(self.SECTCRE, key): raise InvalidWriteError( f"Cannot write key {key}; begins with section pattern") diff --git a/Lib/test/test_configparser.py b/Lib/test/test_configparser.py index 7d1e68fe38100e..dfbdd968e6a546 100644 --- a/Lib/test/test_configparser.py +++ b/Lib/test/test_configparser.py @@ -9,6 +9,65 @@ from test.support import os_helper +class WriteWhitespaceKeysTestCase(unittest.TestCase): + + def test_write_rejects_whitespace_keys(self): + keys = (' key', 'key ', '\tkey', 'key\t', '\u00a0key', + 'key\u00a0', ' \t') + for parser_type in (configparser.ConfigParser, + configparser.RawConfigParser): + for key in keys: + for section in ('section', configparser.DEFAULTSECT, + configparser.UNNAMED_SECTION): + for value in ('value', None): + for spaced in (True, False): + with self.subTest(parser=parser_type, key=key, + section=section, + value=value, spaced=spaced): + parser = parser_type( + allow_no_value=True, + allow_unnamed_section=True) + if section != configparser.DEFAULTSECT: + parser.add_section(section) + parser.set(section, 'first', 'keep') + parser.set(section, key, value) + with self.assertRaisesRegex( + configparser.InvalidWriteError, + 'leading or trailing whitespace'): + parser.write(io.StringIO(), + space_around_delimiters=spaced) + self.assertEqual(parser.get(section, key), + value) + self.assertEqual(parser.get(section, 'first'), + 'keep') + + def test_write_preserves_internal_whitespace(self): + for key in ('two words', 'two\twords', 'two\u00a0words'): + for spaced in (True, False): + with self.subTest(key=key, spaced=spaced): + parser = configparser.ConfigParser(delimiters=('->',)) + parser['section'] = {key: 'value'} + output = io.StringIO() + parser.write(output, space_around_delimiters=spaced) + restored = configparser.ConfigParser(delimiters=('->',)) + restored.read_string(output.getvalue()) + self.assertEqual(dict(restored['section']), + dict(parser['section'])) + + def test_optionxform_can_strip_whitespace(self): + class StrippingParser(configparser.ConfigParser): + def optionxform(self, option): + return super().optionxform(option).strip() + + parser = StrippingParser() + parser['section'] = {' key ': 'value'} + output = io.StringIO() + parser.write(output) + restored = StrippingParser() + restored.read_string(output.getvalue()) + self.assertEqual(dict(restored['section']), {'key': 'value'}) + + class SortedDict(collections.UserDict): def items(self): diff --git a/Misc/NEWS.d/next/Library/2026-09-18-03-00-00.gh-issue-96239.SpaceKeys.rst b/Misc/NEWS.d/next/Library/2026-09-18-03-00-00.gh-issue-96239.SpaceKeys.rst new file mode 100644 index 00000000000000..f5826765ea2d07 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-18-03-00-00.gh-issue-96239.SpaceKeys.rst @@ -0,0 +1,4 @@ +:mod:`configparser` now raises :exc:`~configparser.InvalidWriteError` +when writing an option name with leading or trailing whitespace, +which would otherwise be read back as a different option or as a +continuation of the previous value. In-memory option names are unchanged.