diff --git a/CHANGELOG.md b/CHANGELOG.md index 26584a1..aa5c981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - Fix `comment()` producing invalid TOML for a multiline string by prefixing every line with `#`, not just the first. ([#449](https://github.com/python-poetry/tomlkit/issues/449)) - Fix the separator comma being swallowed by a trailing comment when appending a key to a multiline inline table, leaving the new key without a separator so the result no longer round-trips. ([#512](https://github.com/python-poetry/tomlkit/issues/512)) - Fix a `KeyAlreadyPresent` error when parsing or accessing an out-of-order table whose array-of-tables elements are split across the table's parts. ([#505](https://github.com/python-poetry/tomlkit/issues/505)) +- Out-of-order value-vs-table and dotted-key-vs-table redefinitions are now rejected at parse time instead of being silently accepted or raising only on access. The parser also detects when a non-dotted key is a prefix of an existing dotted key, matching the stdlib `tomllib` behaviour. ([#523](https://github.com/python-poetry/tomlkit/issues/523)) ## [0.15.0] - 2026-05-10 diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 1cdfb9c..060b4dd 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -16,8 +16,9 @@ from tomlkit import ws from tomlkit._utils import _utc from tomlkit.api import document -from tomlkit.exceptions import KeyAlreadyPresent from tomlkit.exceptions import NonExistentKey +from tomlkit.exceptions import ParseError +from tomlkit.exceptions import TOMLKitError from tomlkit.toml_document import TOMLDocument @@ -588,18 +589,39 @@ def test_unwrap_out_of_order_tables() -> None: def test_unwrap_preserves_raise_on_invalid_out_of_order_fragment() -> None: - # Regression guard for the unwrap() fast path: this document is actually - # *invalid* TOML -- `b` is a value under [a], then reopened as a table by - # the out-of-order [a.b]. Ideally tomlkit would reject it at parse (it does - # for the in-order form, and so does the stdlib tomllib); today the conflict - # is only detected lazily when the out-of-order proxy is built, so it - # surfaces at access/unwrap time. This test does NOT bless that deferred - # timing -- it only pins that the faster unwrap() keeps going through the - # proxy and still raises, rather than silently merging the conflict into a - # corrupted dict. - doc = parse("[a]\nb = true\n[zz]\nq = 9\n[a.b]\narr = [1, 2]\n") - with pytest.raises(KeyAlreadyPresent): - doc.unwrap() + # Regression guard: this document is actually *invalid* TOML -- `b` is a value + # under [a], then reopened as a table by the out-of-order [a.b]. The in-order + # form and stdlib tomllib both reject it. Since the Container.append concrete- + # +super fix, tomlkit now rejects it at parse time as well. + with pytest.raises(ParseError): + parse("[a]\nb = true\n[zz]\nq = 9\n[a.b]\narr = [1, 2]\n") + + +def test_reject_out_of_order_dotted_key_redefinition_at_parse() -> None: + # https://github.com/python-poetry/tomlkit/issues/523 + # A dotted key (b.c) creates an implicit table definition that is later + # redefined by an explicit [a.b] table header after an unrelated table [zz]. + # This is invalid TOML (defining a table multiple times) and must be rejected + # at parse, not silently accepted. + with pytest.raises(ParseError): + parse("[a]\nb.c = 1\n[zz]\nq = 9\n[a.b]\nd = 2\n") + + +def test_reject_out_of_order_dotted_prefix_at_parse() -> None: + # https://github.com/python-poetry/tomlkit/issues/523 + # A non-dotted candidate key (b) that is a prefix of an existing dotted key + # (b.c) means the table would be defined twice — once implicitly by the + # dotted key and once explicitly by the table header. This must raise. + with pytest.raises((ParseError, TOMLKitError)): + parse("[a]\nb.c=1\n[a.b]\nd=2\n") + + +def test_valid_out_of_order_independent_tables() -> None: + # [zz] splits unrelated tables; the concrete+super merge must not break + # valid out-of-order extensions. + doc = parse("[a]\nx=1\n[zz]\n[a.b]\nc=1\n") + assert doc.unwrap() == {"a": {"x": 1, "b": {"c": 1}}, "zz": {}} + assert doc.as_string() == "[a]\nx=1\n[zz]\n[a.b]\nc=1\n" def test_out_of_order_table_merges_aot_fragments() -> None: diff --git a/tomlkit/container.py b/tomlkit/container.py index 75e0901..e5daadf 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -317,6 +317,16 @@ def append( self._validate_table_candidate(current, item) elif not item.is_super_table(): raise KeyAlreadyPresent(key) + else: + # An existing concrete table (current) is being extended by + # a super-table (item) — e.g. [a] b=1 then [a.b] c=2 out of + # order, or [a] b.c=1 then [a.b] d=2. Validate that the + # super-table does not redefine any existing key, raising + # early at parse time. When validation passes, fall through + # — _raw_append below will create an out-of-order entry and + # preserve table ordering in the document. + assert isinstance(current, Table) + self._validate_table_candidate(current, item) elif isinstance(item, AoT): if not isinstance(current, AoT): # Tried to define an AoT after a table with the same name. @@ -360,6 +370,8 @@ def append( previous_item.trivia.trail += "\n" self._raw_append(key, item) + if validate and key is not None: + self._validate_out_of_order_table(key) return self def _validate_table_candidate(self, current: Table, candidate: Table) -> None: @@ -384,6 +396,12 @@ def _validate_table_candidate(self, current: Table, candidate: Table) -> None: continue if not k.is_dotted(): + # Even when the candidate key itself is not dotted, an + # existing dotted key may already use it as a prefix — + # e.g. [a] b.c=1 then [a.b] d=2 (b prefixes b.c). + for existing_key in current.value._map: + if existing_key.is_dotted() and next(iter(existing_key)) == k: + raise TOMLKitError("Redefinition of an existing table") continue head = next(iter(k))