From 559a3410bc43d3a246661eeb4a17208f33989648 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Fri, 24 Jul 2026 17:21:50 +0800 Subject: [PATCH] core/config: write LF strings as TOML multi-line literals --- src/plyngent/config/store.py | 26 ++++++++++++++++++++++---- tests/test_config/test_config.py | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/plyngent/config/store.py b/src/plyngent/config/store.py index 4528489..19a43fd 100644 --- a/src/plyngent/config/store.py +++ b/src/plyngent/config/store.py @@ -1,7 +1,7 @@ from __future__ import annotations from types import MappingProxyType -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast import msgspec import tomlkit @@ -370,6 +370,24 @@ class ConfigStore: self._document[key] = tomlkit.table() return cast("MutableMapping[str, object]", self._document[key]) + @staticmethod + def _encode_toml_value(value: object) -> object: + """Encode builtins for tomlkit; strings with LF become multi-line literals.""" + if isinstance(value, str): + if "\n" in value: + return tomlkit.string(value, multiline=True) + return value + if isinstance(value, list): + arr: object = tomlkit.array() + append = cast("Any", arr).append + for item in cast("list[object]", value): + append(ConfigStore._encode_toml_value(item)) + return arr + if isinstance(value, dict): + # Nested dicts at section level are rare; providers use dedicated helpers. + return ConfigStore._to_inline_table(cast("Mapping[str, object]", value)) + return value + def _sync_section(self, key: str, data: object) -> None: raw: dict[str, object] = msgspec.to_builtins(data) if not raw: @@ -381,7 +399,7 @@ class ConfigStore: if k not in raw: del section[k] for k, v in raw.items(): - section[k] = v + section[k] = self._encode_toml_value(v) def _sync_database_section(self) -> None: """Sync ``[database]`` to the document.""" @@ -403,7 +421,7 @@ class ConfigStore: if isinstance(value, dict): table[key] = ConfigStore._to_inline_table(cast("Mapping[str, object]", value)) else: - table[key] = value + table[key] = ConfigStore._encode_toml_value(value) return table @staticmethod @@ -427,7 +445,7 @@ class ConfigStore: elif isinstance(value, dict): entry[key] = ConfigStore._to_inline_table(cast("Mapping[str, object]", value)) else: - entry[key] = value + entry[key] = ConfigStore._encode_toml_value(value) return entry def _sync_providers_section(self) -> None: diff --git a/tests/test_config/test_config.py b/tests/test_config/test_config.py index fceb8d1..4067dc5 100644 --- a/tests/test_config/test_config.py +++ b/tests/test_config/test_config.py @@ -282,6 +282,30 @@ def test_write_models_as_inline_tables(tmp_path: Path) -> None: assert again.providers["local"].models["gpt-test"].cost_factor == 2 +def test_write_lf_strings_as_multiline(tmp_path: Path) -> None: + """Strings containing LF are written as TOML multi-line strings (\"\"\"…\"\"\").""" + from plyngent.config import AgentConfig + + path = tmp_path / "ml-string.toml" + config = ConfigStore(path=path, document=tomlkit.document()) + # AgentConfig has no public setter; exercise the TOML encoder via write path. + config._agent = AgentConfig( + system_prompt="Line one.\nLine two.", + tool_directives="Use tools.\nBe careful.", + ) + config.write() + text = path.read_text(encoding="utf-8") + assert 'system_prompt = """' in text + assert "Line one." in text and "Line two." in text + assert 'tool_directives = """' in text + # Escaped single-line form should not appear for these values. + assert 'system_prompt = "Line one.\\nLine two."' not in text + + again = plyngent.config.load(path) + assert again.agent_config.system_prompt == "Line one.\nLine two." + assert again.agent_config.tool_directives == "Use tools.\nBe careful." + + def test_update_config() -> None: file = Path(__file__).parent / "plyngent-edit-2.toml" _ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file)