diff --git a/CHANGES.rst b/CHANGES.rst index 26a9037..e5bcf4b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,9 @@ Unreleased * raise ``InterpolationError`` instead of leaking a raw ``TypeError`` when a value interpolates a reference to an option whose value is a list +* quote values on writing with ``list_values=False`` when they contain ``#`` + or start with a quote character, and unquote them again on reading, so + written output can be loaded back unchanged (issue #270) Release 5.0.9 """"""""""""" diff --git a/src/configobj/__init__.py b/src/configobj/__init__.py index 2315a68..761f878 100644 --- a/src/configobj/__init__.py +++ b/src/configobj/__init__.py @@ -1709,7 +1709,8 @@ def _quote(self, value, multiline=True): * Obey list syntax for empty and single member lists. If ``list_values=False`` then the value is only quoted if it contains - a ``\\n`` (is multiline) or '#'. + a ``\\n`` (is multiline), starts with a quote character, or contains + '#', so that written output can be read back unchanged. If ``write_empty_values`` is set, and the value is an empty string, it won't be quoted. @@ -1737,15 +1738,23 @@ def _quote(self, value, multiline=True): if not value: return '""' - no_lists_no_quotes = not self.list_values and '\n' not in value and '#' not in value + no_lists_no_quotes = (not self.list_values and '\n' not in value + and '#' not in value and value[0] not in ('"', "'")) need_triple = multiline and ((("'" in value) and ('"' in value)) or ('\n' in value )) hash_triple_quote = multiline and not need_triple and ("'" in value) and ('"' in value) and ('#' in value) 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: - # we don't quote if ``list_values=False`` - quot = noquot + if no_lists_no_quotes: + # we don't quote if ``list_values=False`` + # and the bare form cannot be misparsed when read back + quot = noquot + else: + # a ``#`` would truncate the value at reading time, and + # a leading quote character makes the line unparseable, + # so quote to keep the output re-parseable + quot = self._get_single_quote(value) # for normal values either single or double quotes will do elif '\n' in value: # will only happen if multiline is off - e.g. '\n' in key @@ -1799,8 +1808,16 @@ def _handle_value(self, value): mat = self._nolistvalue.match(value) if mat is None: raise SyntaxError() - # NOTE: we don't unquote here - return mat.groups() + entry = mat.group(1) + comment = mat.group(2) + if (len(entry) >= 2 and entry[0] == entry[-1] + and entry[0] in ('"', "'") + and entry[0] not in entry[1:-1]): + # a fully quoted value is unquoted here, symmetrically to + # how ``_quote`` writes it with ``list_values=False``; + # anything else keeps its verbatim text + entry = self._unquote(entry) + return (entry, comment) # mat = self._valueexp.match(value) if mat is None: diff --git a/src/tests/test_configobj.py b/src/tests/test_configobj.py index 6467175..4a9a7f9 100644 --- a/src/tests/test_configobj.py +++ b/src/tests/test_configobj.py @@ -394,8 +394,8 @@ def test_behavior_when_list_values_is_false(): cfg = ConfigObj(cfg_lines(c), list_values=False) assert cfg == { 'key1': 'no quotes', - 'key2': "'single quotes'", - 'key3': '"double quotes"', + 'key2': 'single quotes', + 'key3': 'double quotes', 'key4': '"list", \'with\', several, "quotes"' } @@ -404,7 +404,7 @@ def test_behavior_when_list_values_is_false(): cfg2['key2'] = '''"Value" with 'quotes' !''' assert cfg2.write() == [ "key1 = '''Multiline\nValue'''", - 'key2 = "Value" with \'quotes\' !' + 'key2 = \'\'\'"Value" with \'quotes\' !\'\'\'' ] cfg2.list_values = True @@ -414,6 +414,27 @@ def test_behavior_when_list_values_is_false(): ] +def test_list_values_false_written_output_can_be_read_back(): + # A ``#`` would truncate the value at reading time and a leading quote + # character would make the line unparseable, so such values are quoted + # on writing and unquoted on reading again. + values = [ + 'hello # world', + '"hello" world', + "'quoted' # with hash", + '', + 'no special characters', + ] + for value in values: + cfg = ConfigObj(list_values=False) + cfg['key'] = value + assert ConfigObj(cfg.write(), list_values=False)['key'] == value + + cfg = ConfigObj(list_values=False) + cfg['key'] = 'hello # world' + assert cfg.write() == ['key = "hello # world"'] + + def test_flatten_errors(val, cfg_contents): config = cfg_contents(""" test1=40