diff --git a/CHANGELOG.md b/CHANGELOG.md index faad379..ebc203e 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 b49c90a..b5f31bc 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 e9fdaff..d559bfe 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())