From a842cdeaddd36264e50c1bf6188d3400d8f8b78b Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Sat, 20 Jun 2026 20:10:11 +0200 Subject: [PATCH] Escape multiline strings containing triple quotes in to_hocon HOCONConverter.to_hocon emitted any string containing a newline as a triple-quoted literal, even when the string itself contained a """ sequence. The embedded """ terminates the literal early, so the output either failed to re-parse or silently dropped the """ markers, breaking the to_hocon -> parse round-trip. Only use the triple-quoted form when the value does not contain """; otherwise fall back to the existing escaped single-quoted form (which already escapes newlines), in both the str and ConfigQuotedString branches. --- pyhocon/converter.py | 7 +++++-- tests/test_converter.py | 12 +++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pyhocon/converter.py b/pyhocon/converter.py index 158437f1..bb93e5c1 100644 --- a/pyhocon/converter.py +++ b/pyhocon/converter.py @@ -115,7 +115,10 @@ def to_hocon(cls, config, compact=False, indent=2, level=0): lines += '\n'.join(bet_lines) lines += '\n{indent}]'.format(indent=''.rjust((level - 1) * indent, ' ')) elif isinstance(config, str): - if '\n' in config and len(config) > 1: + # A triple-quoted literal cannot contain the `"""` sequence (it + # would terminate the literal early), so such strings must use the + # escaped single-quoted form instead. + if '\n' in config and len(config) > 1 and '"""' not in config: lines = '"""{value}"""'.format(value=config) # multilines else: lines = '"{value}"'.format(value=cls._escape_string(config)) @@ -127,7 +130,7 @@ def to_hocon(cls, config, compact=False, indent=2, level=0): lines += '?' lines += config.variable + '}' + config.ws elif isinstance(config, ConfigQuotedString): - if '\n' in config.value and len(config.value) > 1: + if '\n' in config.value and len(config.value) > 1 and '"""' not in config.value: lines = '"""{value}"""'.format(value=config.value) # multilines else: lines = '"{value}"'.format(value=cls._escape_string(config.value)) diff --git a/tests/test_converter.py b/tests/test_converter.py index d4d80b0d..f9913933 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,7 +1,7 @@ # -*- encoding: utf-8 -*- from datetime import timedelta -from pyhocon import ConfigTree +from pyhocon import ConfigFactory, ConfigTree from pyhocon.converter import HOCONConverter @@ -108,6 +108,16 @@ def test_format_multiline_string(self): assert 'a = """b\n"""' == to_hocon({'a': 'b\n'}) assert 'a = """\n\n"""' == to_hocon({'a': '\n\n'}) + def test_format_multiline_string_with_triple_quote(self): + # A multiline string that itself contains `"""` cannot use the + # triple-quoted form (the embedded `"""` would terminate the literal + # early), so it must fall back to the escaped single-quoted form and + # still round-trip. + assert r'a = "a\nb\"\"\"c"' == to_hocon({'a': 'a\nb"""c'}) + for value in ('a\nb"""c', 'before\n"""middle"""\nafter', '"""\nleading'): + parsed = ConfigFactory.parse_string(to_hocon({'a': value}))['a'] + assert parsed == value + def test_format_time_delta(self): for time_delta, expected_result in ((timedelta(days=0), 'td = 0 seconds'), (timedelta(days=5), 'td = 5 days'),