From 0cee6d69613dd0d23f9698cdfa7a320186543adc Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Fri, 3 Jul 2026 04:49:42 -0700 Subject: [PATCH] fix: preserve leading newline of multiline string built with string() `tomlkit.string(value, multiline=True)` rendered the value as `""""""`. When `value` starts with a newline the output looks like `"""\n..."""`, and per the TOML spec a newline immediately following the opening delimiter is trimmed on parse. So a value that begins with a newline was silently lost on the very next round-trip: >>> s = tomlkit.string("\nfoo", multiline=True) >>> str(s) '\nfoo' >>> s.as_string() '"""\nfoo"""' >>> str(tomlkit.parse(f"k = {s.as_string()}")["k"]) 'foo' # leading newline dropped The value the String object reports is correct; only its serialized form was wrong, so the corruption only surfaced after re-parsing. Both tomllib and tomlkit's own parser agree the old output decodes to the wrong value, so this is a genuine serialization bug (not a TOML 1.0 vs 1.1 difference). Affects basic and literal multiline strings, including values that start with `\r\n`. Fix: in `String.from_raw`, when the string is multiline and its rendered body starts with a newline, emit an extra leading newline (the one the parser trims) so the value survives round-tripping. This is exactly how the spec suggests representing such a value. Single-line strings and multiline strings without a leading newline are unchanged. An existing `test_create_string` case asserted the old lossy output (`"""\nMy\nString\n"""`); it only checked `as_string()`, never a round-trip, so it had locked in the bug. Updated it to the correct, round-trippable rendering and added a dedicated round-trip regression test covering basic/literal multiline and `\r\n`-leading values. --- CHANGELOG.md | 1 + tests/test_api.py | 26 +++++++++++++++++++++++++- tomlkit/items.py | 7 +++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index faad379e..ebc203ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### Fixed +- Fix `string()` dropping a leading newline of a multiline string on round-trip: a value beginning with a newline is now rendered with an extra leading newline (the one the parser trims after the opening delimiter) so it survives re-parsing. - Fix invalid serialization with a duplicated comma when removing a non-edge element from a parsed inline table. ([#486](https://github.com/python-poetry/tomlkit/pull/486)) - Fix invalid serialization with a duplicated comma when appending or inserting into a comma-first formatted array. ([#499](https://github.com/python-poetry/tomlkit/pull/499)) - Fix `ParseError` when a sub-table extends the last element of an array of tables after an unrelated table. ([#261](https://github.com/python-poetry/tomlkit/issues/261)) diff --git a/tests/test_api.py b/tests/test_api.py index b49c90ac..b5f31bc3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -506,7 +506,7 @@ def test_create_super_table_with_aot() -> None: ({}, "My String\x12", '"My String\\u0012"'), ({}, "My String\x7f", '"My String\\u007f"'), ({"escape": False}, "My String\u0001", '"My String\u0001"'), - ({"multiline": True}, "\nMy\nString\n", '"""\nMy\nString\n"""'), + ({"multiline": True}, "\nMy\nString\n", '"""\n\nMy\nString\n"""'), ({"multiline": True}, 'My"String', '"""My"String"""'), ({"multiline": True}, 'My""String', '"""My""String"""'), ({"multiline": True}, 'My"""String', '"""My""\\"String"""'), @@ -537,6 +537,30 @@ def test_create_string(kwargs: dict[str, Any], example: str, expected: str) -> N assert value.as_string() == expected +@pytest.mark.parametrize( + "kwargs, example", + [ + ({"multiline": True}, "\n"), + ({"multiline": True}, "\nMy\nString\n"), + ({"multiline": True}, "\r\nMy\nString"), + ({"multiline": True, "literal": True}, "\nMy\nString"), + ({"multiline": True, "literal": True}, "\r\nMy\nString"), + ], +) +def test_create_multiline_string_with_leading_newline_round_trips( + kwargs: dict[str, Any], example: str +) -> None: + """A multiline string whose value starts with a newline must survive a + round-trip. The parser trims a newline immediately following the opening + delimiter, so the rendered form has to account for it (otherwise the + leading newline is silently dropped when the output is parsed again). + """ + value = tomlkit.string(example, **kwargs) + assert str(value) == example + reparsed = parse(f"k = {value.as_string()}\n")["k"] + assert str(reparsed) == example + + @pytest.mark.parametrize( "kwargs, example", [ diff --git a/tomlkit/items.py b/tomlkit/items.py index e9fdaffd..d559bfed 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -2251,6 +2251,13 @@ def from_raw( escaped = type_.escaped_sequences string_value = escape_string(value, escaped) if escape and escaped else value + if type_.is_multiline() and string_value[:1] in ("\n", "\r"): + # A newline immediately following the opening delimiter of a + # multiline string is trimmed by the parser, so a value that + # starts with a newline would otherwise lose it on round-trip. + # Emit an extra leading newline (the trimmed one) to preserve it. + string_value = "\n" + string_value + return cls(type_, decode(value), string_value, Trivia())