diff --git a/benedict/serializers/toml.py b/benedict/serializers/toml.py index fc3da779..5eb7719f 100644 --- a/benedict/serializers/toml.py +++ b/benedict/serializers/toml.py @@ -1,10 +1,3 @@ -try: - import toml - - toml_installed = True -except ModuleNotFoundError: # pragma: no cover - toml_installed = False - try: # python >= 3.11 import tomllib @@ -13,6 +6,20 @@ except ImportError: tomllib_available = False +try: + import tomli + + tomli_installed = True +except ModuleNotFoundError: + tomli_installed = False + +try: + import tomli_w + + tomli_w_installed = True +except ModuleNotFoundError: + tomli_w_installed = False + from typing import Any from benedict.extras import require_toml @@ -35,11 +42,11 @@ def decode(self, s: str, **kwargs: Any) -> Any: if tomllib_available: data = tomllib.loads(s, **kwargs) else: - require_toml(installed=toml_installed) - data = toml.loads(s, **kwargs) + require_toml(installed=tomli_installed) + data = tomli.loads(s, **kwargs) return data def encode(self, d: Any, **kwargs: Any) -> str: - require_toml(installed=toml_installed) - data = toml.dumps(dict(d), **kwargs) - return data + require_toml(installed=tomli_w_installed) + result: str = tomli_w.dumps(dict(d), **kwargs) + return result diff --git a/pyproject.toml b/pyproject.toml index b349144b..2a52aec5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,7 +141,8 @@ schema = [ "pydantic >= 2.0.0, < 3.0.0", ] toml = [ - "toml >= 0.10.2, < 1.0.0", + "tomli >= 2.0.0, < 3.0.0; python_version < '3.11'", + "tomli-w >= 1.0.0, < 2.0.0", ] xls = [ "openpyxl >= 3.0.0, < 4.0.0", diff --git a/requirements.txt b/requirements.txt index c1643f52..36fda83d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,8 @@ python-fsutil == 0.16.1 python-slugify == 8.0.4 pyyaml == 6.0.3 requests == 2.33.1 -toml == 0.10.2 +tomli == 2.0.2 +tomli-w == 1.2.0 typing_extensions >= 4.14.1 urllib3 >= 2.6.3 xlrd == 2.0.2 diff --git a/tests/dicts/io/test_io_dict_toml.py b/tests/dicts/io/test_io_dict_toml.py index f7232bcd..955a19e1 100644 --- a/tests/dicts/io/test_io_dict_toml.py +++ b/tests/dicts/io/test_io_dict_toml.py @@ -50,9 +50,9 @@ def test_from_toml_with_valid_data(self) -> None: @unittest.skipIf( tomllib_available, - "standard tomlib is available, exception will not be raised", + "standard tomllib is available, exception will not be raised", ) - @patch("benedict.serializers.toml.toml_installed", False) + @patch("benedict.serializers.toml.tomli_installed", False) def test_from_toml_with_valid_data_but_toml_extra_not_installed(self) -> None: j = """ a = 1 @@ -184,7 +184,7 @@ def test_to_toml_file(self) -> None: self.assertFileExists(filepath) self.assertEqual(d, IODict.from_toml(filepath)) - @patch("benedict.serializers.toml.toml_installed", False) + @patch("benedict.serializers.toml.tomli_w_installed", False) def test_to_toml_with_extra_not_installed(self) -> None: d = IODict( { diff --git a/tests/serializers/test_toml_serializer.py b/tests/serializers/test_toml_serializer.py index e16c9b2f..1fbc36bc 100644 --- a/tests/serializers/test_toml_serializer.py +++ b/tests/serializers/test_toml_serializer.py @@ -1,17 +1,82 @@ import unittest -# from benedict.serializers import TOMLSerializer +from benedict import benedict +from benedict.dicts.io import IODict +from benedict.serializers import TOMLSerializer class toml_serializer_test_case(unittest.TestCase): """ This class describes a toml serializer test case. + + Regression coverage for issue #439 — the uiri/toml encoder crashes + on certain strings. These tests pin the encode path to a library + that handles them correctly and guard against regression. """ - def test_decode_toml(self) -> None: - # TODO - pass + def test_encode_ansi_control_character(self) -> None: + """Scenario 1 — falsification clause #1. + + `benedict({"color": "\\033[31m"}).to_toml()` must not raise. On + baseline (uiri/toml) this raises IndexError in the encoder. + """ + payload = {"color": "\033[31m"} + encoded = benedict(payload).to_toml() + self.assertIsInstance(encoded, str) + self.assertGreater(len(encoded), 0) + # Round-trip: decoded value must equal the original string. + decoded = IODict.from_toml(encoded) + self.assertEqual(decoded["color"], "\033[31m") + + def test_encode_issue_439_literal_examples(self) -> None: + """Scenario 2 — regression guard for issue #439's cited examples. + + These pass on baseline (literal backslashes, not control chars). + Kept so the encoder swap does not silently regress them. + """ + payload = { + "reset": "\\033\\[00;00m", + "lightblue": "\\033\\[01;30m", + } + encoded = benedict(payload).to_toml() + self.assertIsInstance(encoded, str) + decoded = IODict.from_toml(encoded) + self.assertEqual(decoded["reset"], "\\033\\[00;00m") + self.assertEqual(decoded["lightblue"], "\\033\\[01;30m") + + def test_roundtrip_control_chars_and_unicode(self) -> None: + """Scenario 4 — round-trip integrity across tricky values.""" + payload = { + "ansi_red": "\033[31m", + "ansi_reset": "\033[0m", + "bell": "\x07", + "tab_and_newline": "a\tb\nc", + "unicode_emoji": "benedict 🎩", + "backslash": "path\\to\\file", + "quotes": 'he said "hi"', + } + encoded = benedict(payload).to_toml() + decoded = IODict.from_toml(encoded) + for key, value in payload.items(): + self.assertEqual(decoded[key], value, f"round-trip mismatch for {key!r}") + + def test_encode_nested_dict(self) -> None: + """Structural coverage — nested dicts still encode correctly.""" + payload = { + "section": { + "key": "value", + "control": "\033[31m", + } + } + encoded = benedict(payload).to_toml() + decoded = IODict.from_toml(encoded) + self.assertEqual(decoded["section"]["key"], "value") + self.assertEqual(decoded["section"]["control"], "\033[31m") - def test_encode_toml(self) -> None: - # TODO - pass + def test_serializer_decode_roundtrip(self) -> None: + """Direct serializer-level round-trip (bypasses IODict convenience layer).""" + serializer = TOMLSerializer() + payload = {"color": "\033[31m", "count": 42} + encoded = serializer.encode(payload) + decoded = serializer.decode(encoded) + self.assertEqual(decoded, payload)