From b88569eff7fcd41afbdbba04297b39efb6979d8d Mon Sep 17 00:00:00 2001 From: Madan Kumar Date: Mon, 14 Sep 2026 01:12:29 +0530 Subject: [PATCH] gh-157456: Fix configparser parsing an empty value with a whitespace-ending delimiter An option whose delimiter ends in whitespace (e.g. ``delimiters=(' ',)``) and has an empty value could not be parsed, and the parser could not read back the output produced by ``write()``: ``_read`` matched option lines against ``line.clean``, which is ``str.strip``-ed on both sides, so for a whitespace delimiter the trailing whitespace that separates the key from the (empty) value was removed and ``OPTCRE`` no longer matched. Match the option regex against a form that preserves trailing whitespace. The matched value is stripped immediately afterwards, so ordinary values keep no trailing whitespace, and the other uses of ``line.clean`` are unchanged. Add a regression test; the gh-156353 whitespace-delimiter tests only used non-empty values, so this edge was unpinned. --- Lib/configparser.py | 13 ++++++++++--- Lib/test/test_configparser.py | 17 +++++++++++++++++ ...26-09-14-01-10-00.gh-issue-157456.p3Rw8k.rst | 4 ++++ 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-14-01-10-00.gh-issue-157456.p3Rw8k.rst diff --git a/Lib/configparser.py b/Lib/configparser.py index 88015ef60865698..a889acca2b0077b 100644 --- a/Lib/configparser.py +++ b/Lib/configparser.py @@ -599,8 +599,9 @@ def __init__(self, full_prefixes, inline_prefixes): ) self.pattern = re.compile('|'.join(itertools.chain(full_patterns, inline_patterns))) - def strip(self, text): - return self.pattern.sub('', text).rstrip() + def strip(self, text, *, rstrip=True): + text = self.pattern.sub('', text) + return text.rstrip() if rstrip else text def wrap(self, text): return _Line(text, self) @@ -1156,7 +1157,13 @@ def _handle_option(self, st, line, fpname): # an option line? st.indent_level = st.cur_indent_level - mo = self._optcre.match(line.clean) + # `line.clean` strips trailing whitespace, but for a whitespace delimiter + # that whitespace is the separator before an (empty) value, so match + # against a form that preserves it. `optval` is stripped below, so + # ordinary values are unaffected. + match_target = self._comments.strip(str(line).strip('\r\n').lstrip(), + rstrip=False) + mo = self._optcre.match(match_target) if not mo: # a non-fatal parsing error occurred. set up the # exception but keep going. the exception will be diff --git a/Lib/test/test_configparser.py b/Lib/test/test_configparser.py index 7d1e68fe38100ef..92e076819728da2 100644 --- a/Lib/test/test_configparser.py +++ b/Lib/test/test_configparser.py @@ -385,6 +385,23 @@ def test_any_delimiter(self, delimiter, space_before, space_after): self.assertEqual(cf.options('all'), ['foo']) self.assertEqual(cf.get('all', 'foo'), 'bar=baz') + def test_whitespace_delimiter_empty_value(self): + # gh-157456: an option with a whitespace-ending delimiter and an empty + # value must parse rather than raise ParsingError, and the parser must + # read back the output produced by write(). + cf = self.newconfig(delimiters=(' ',)) + cf.read_string("[all]\nkey \n") + # With allow_no_value a whitespace delimiter cannot distinguish an + # empty value from a valueless option, so the value is None there; + # otherwise it is the empty string. + expected = None if cf._allow_no_value else '' + self.assertEqual(cf.get('all', 'key'), expected) + output = io.StringIO() + cf.write(output) + cf2 = self.newconfig(delimiters=(' ',)) + cf2.read_string(output.getvalue()) + self.assertEqual(cf2.get('all', 'key'), expected) + def test_basic_from_dict(self): config = { "Foo Bar": { diff --git a/Misc/NEWS.d/next/Library/2026-09-14-01-10-00.gh-issue-157456.p3Rw8k.rst b/Misc/NEWS.d/next/Library/2026-09-14-01-10-00.gh-issue-157456.p3Rw8k.rst new file mode 100644 index 000000000000000..b3e7f73408ce27b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-14-01-10-00.gh-issue-157456.p3Rw8k.rst @@ -0,0 +1,4 @@ +Fix :mod:`configparser` failing to parse an option with an empty value when +the delimiter ends in whitespace (for example ``delimiters=(' ',)``). Such an +option, including the output produced by :meth:`~configparser.RawConfigParser.write`, +is now read back correctly.