From aed10b8760025a27a1af8ace47eea78fb2f117e0 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Sat, 20 Jun 2026 20:38:27 +0200 Subject: [PATCH] Quote keys containing '=' when writing A config key containing '=' was written unquoted, so on the next parse the first '=' was read as the key/value divider, splitting the key and corrupting the value: ConfigObj({'a = b': 'val'}) round-tripped to {'a': 'b = val'}, and keys like "x " produced an unparseable file (issue #273). _quote() is shared by keys, values, list elements and section names; '=' is only special for a key (the first '=' on a line is the divider). Add an is_key flag and, when set and the key contains '=', force a quoted form via _get_single_quote(). _write_line passes is_key=True for the keyword; values, list elements and section markers are unchanged. Fixes #273 --- src/configobj/__init__.py | 11 ++++++++--- src/tests/test_configobj.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/configobj/__init__.py b/src/configobj/__init__.py index 0ae986a..0e6ed3d 100644 --- a/src/configobj/__init__.py +++ b/src/configobj/__init__.py @@ -1760,7 +1760,7 @@ def _unquote(self, value): return value - def _quote(self, value, multiline=True): + def _quote(self, value, multiline=True, is_key=False): """ Return a safely quoted version of a value. @@ -1808,7 +1808,12 @@ def _quote(self, value, multiline=True): check_for_single = (no_lists_no_quotes or not need_triple) and not hash_triple_quote if check_for_single: - if not self.list_values: + if is_key and '=' in value: + # A key containing '=' must be quoted, otherwise the first + # '=' is read back as the key/value divider, splitting the + # key and corrupting the value on the next parse. + quot = self._get_single_quote(value) + elif not self.list_values: # we don't quote if ``list_values=False`` quot = noquot # for normal values either single or double quotes will do @@ -1992,7 +1997,7 @@ def _write_line(self, indent_string, entry, this_entry, comment): else: val = repr(this_entry) return '%s%s%s%s%s' % (indent_string, - self._decode_element(self._quote(entry, multiline=False)), + self._decode_element(self._quote(entry, multiline=False, is_key=True)), self._a_to_u(' = '), val, self._decode_element(comment)) diff --git a/src/tests/test_configobj.py b/src/tests/test_configobj.py index 37ddbf0..03affe7 100644 --- a/src/tests/test_configobj.py +++ b/src/tests/test_configobj.py @@ -1295,6 +1295,19 @@ def test_hash_escaping(self, empty_cfg): empty_cfg.write(collector) assert collector.getvalue() == b'a = "b # something", "c # something"\n' + def test_key_with_equals_is_quoted(self, empty_cfg): + # issue #273: a key containing '=' was written unquoted, so on the + # next parse the first '=' was read as the key/value divider, + # splitting the key and corrupting the value. + empty_cfg.newlines = '\n' + empty_cfg['a = b'] = 'val' + collector = io.BytesIO() + empty_cfg.write(collector) + assert collector.getvalue() == b'"a = b" = val\n' + # and the written form round-trips back to the original key/value + collector.seek(0) + assert dict(ConfigObj(collector)) == {'a = b': 'val'} + def test_detecting_line_endings_from_existing_files(self): for expected_line_ending in ('\r\n', '\n'): with open('temp', 'w') as h: