From 715989b0c209b795b89845acb6a568b07566a44e Mon Sep 17 00:00:00 2001 From: Huanyi Xie <74809554+xiehuanyi@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:05:23 +0300 Subject: [PATCH 1/2] Re-escape strings concatenated across quote styles --- tests/test_items.py | 31 +++++++++++++++++++++++++++++++ tomlkit/items.py | 9 +++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/tests/test_items.py b/tests/test_items.py index bbe1b2ed..325f9b77 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -998,6 +998,37 @@ def test_string_add_escapes_appended_content() -> None: assert k.as_string() == '"foo bar"' +@pytest.mark.parametrize( + "left_style", [(False, False), (False, True), (True, False), (True, True)] +) +@pytest.mark.parametrize( + "right_style", [(False, False), (False, True), (True, False), (True, True)] +) +def test_string_add_quote_styles_roundtrip( + left_style: tuple[bool, bool], right_style: tuple[bool, bool] +) -> None: + left = api.string("prefix/", literal=left_style[0], multiline=left_style[1]) + right = api.string(r"C:\temp", literal=right_style[0], multiline=right_style[1]) + combined = left + right + expected = str(left) + str(right) + + assert str(combined) == expected + assert combined.type is left.type + assert parse(api.dumps({"value": combined}))["value"] == expected + + +def test_string_add_literal_decodes_basic_escape() -> None: + left = api.string("prefix/", literal=True) + right = parse(r'value = "\u0041"')["value"] + combined = left + right + assert parse(api.dumps({"value": combined}))["value"] == "prefix/A" + + +def test_string_add_multiline_preserves_carriage_return() -> None: + combined = api.string("prefix/", multiline=True) + api.string("\r") + assert parse(api.dumps({"value": combined}))["value"] == "prefix/\r" + + def test_tables_behave_like_dicts() -> None: t = item({"foo": "bar"}) diff --git a/tomlkit/items.py b/tomlkit/items.py index 26b01097..1b933778 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -2277,13 +2277,14 @@ def type(self) -> StringType: def __add__(self, other: str) -> String: result = super().__add__(other) - if isinstance(other, String): + if isinstance(other, String) and other._t is self._t: original = self._original + other._original else: escaped = self._t.escaped_sequences - original = self._original + ( - escape_string(other, escaped) if escaped else other - ) + appended = escape_string(other, escaped) if escaped else other + if self._t is StringType.MLB: + appended = _BARE_CR.sub(r"\\r", appended) + original = self._original + appended return self._new(result, original) From e4bd7bf7a86d0315a811a2f666ee60b9126af3d4 Mon Sep 17 00:00:00 2001 From: Huanyi Xie <74809554+xiehuanyi@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:12:48 +0300 Subject: [PATCH 2/2] Validate concatenated literal string content --- tests/test_items.py | 53 +++++++++++++++++++++++++++++++++++++++++++++ tomlkit/items.py | 4 +++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/tests/test_items.py b/tests/test_items.py index 325f9b77..e246f243 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -22,6 +22,7 @@ from tomlkit import api from tomlkit import parse from tomlkit.container import OutOfOrderTableProxy +from tomlkit.exceptions import InvalidStringError from tomlkit.exceptions import NonExistentKey from tomlkit.items import Array from tomlkit.items import Bool @@ -1029,6 +1030,58 @@ def test_string_add_multiline_preserves_carriage_return() -> None: assert parse(api.dumps({"value": combined}))["value"] == "prefix/\r" +@pytest.mark.parametrize("as_item", [False, True]) +@pytest.mark.parametrize( + "multiline, value", + [ + (False, "newline\n"), + (False, "apostrophe'"), + (False, "carriage\r"), + (False, "crlf\r\n"), + (True, "carriage\r"), + (True, "triple'''"), + ], +) +def test_string_add_rejects_invalid_literal_content( + multiline: bool, value: str, as_item: bool +) -> None: + left = api.string("prefix/", literal=True, multiline=multiline) + right = api.string(value) if as_item else value + + with pytest.raises(InvalidStringError): + left + right + + assert left == "prefix/" + assert ( + left.as_string() + == api.string("prefix/", literal=True, multiline=multiline).as_string() + ) + + +def test_string_add_rejects_literal_delimiter_across_boundary() -> None: + left = api.string("prefix''", literal=True, multiline=True) + right = api.string("'suffix", literal=True, multiline=True) + + with pytest.raises(InvalidStringError): + left + right + + +@pytest.mark.parametrize("as_item", [False, True]) +@pytest.mark.parametrize("prefix", ["", "prefix/"]) +@pytest.mark.parametrize("value", ["\nline", "quote''", "crlf\r\n", "tab\t"]) +def test_string_add_multiline_literal_valid_content( + value: str, prefix: str, as_item: bool +) -> None: + left = api.string(prefix, literal=True, multiline=True).comment("preserved") + right = api.string(value) if as_item else value + combined = left + right + + assert combined.type is StringType.MLL + assert combined == prefix + value + assert combined.trivia.comment == left.trivia.comment + assert parse(api.dumps({"value": combined}))["value"] == prefix + value + + def test_tables_behave_like_dicts() -> None: t = item({"foo": "bar"}) diff --git a/tomlkit/items.py b/tomlkit/items.py index 1b933778..785718aa 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -2277,7 +2277,9 @@ def type(self) -> StringType: def __add__(self, other: str) -> String: result = super().__add__(other) - if isinstance(other, String) and other._t is self._t: + if self._t.is_literal(): + original = String.from_raw(result, self._t)._original + elif isinstance(other, String) and other._t is self._t: original = self._original + other._original else: escaped = self._t.escaped_sequences