Skip to content

Commit dc5b870

Browse files
fix: route nested dicts through complex-replace in _create_at
MergeInto is scoped to flat mappings (uniform indent); nested values produced incorrect indentation due to yamlpatch's trim_start() logic. Gate the two-step add+replace fallback on whether the dict contains nested dicts/lists, preserving merge_into for the common flat case. - Add depth check in _create_at to select the correct code path - Document merge_into's flat-only scope in ops.rs docstring - Update design spec to reflect the flat/nested split - Add regression test for nested dict via intermediate keys
1 parent e8498bb commit dc5b870

4 files changed

Lines changed: 33 additions & 3 deletions

File tree

doc/specs/2026-05-13-yamltrip-design.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,11 @@ All return a new `Document`.
115115

116116
1. **Full path exists:** delegates to `replace()`.
117117
2. **Partial path exists:** walks down to the deepest existing key, then uses
118-
yamlpatch `MergeInto` to create the remaining nested structure in a single
119-
operation.
118+
yamlpatch `MergeInto` for flat mapping values (scalar-only entries) or a
119+
two-step `Add` placeholder + `Replace` for nested values (dicts/lists inside
120+
the value). `MergeInto` uses uniform-indent serialization that only handles
121+
one nesting level; the `Replace` path routes through complex-replace which
122+
preserves relative indentation at arbitrary depth.
120123
3. **No path exists:** uses yamlpatch `Add` with a nested value to create the
121124
entire path in a single operation.
122125

src/ops.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ impl PyOp {
7171
}
7272

7373
/// Merge key-value pairs into an existing mapping.
74+
///
75+
/// The `updates` map must contain flat (scalar) values only. Nested
76+
/// mappings will produce incorrect indentation due to yamlpatch's
77+
/// uniform-indent serialization in `handle_block_mapping_addition`.
78+
/// For nested values, use `Op.add` + `Op.replace` to route through
79+
/// the complex-replace path which preserves relative indentation.
7480
#[staticmethod]
7581
fn merge_into(key: &str, updates: &Bound<'_, PyAny>) -> PyResult<Self> {
7682
let dict = updates.cast::<pyo3::types::PyDict>().map_err(|_| {

src/yamltrip/document.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,20 @@ def _create_at(
286286
for k in reversed(child_keys[1:]):
287287
nested_value = {k: nested_value}
288288
route = _make_route(parent_keys)
289-
if isinstance(nested_value, dict):
289+
if isinstance(nested_value, dict) and any(
290+
isinstance(v, (dict, list)) for v in nested_value.values()
291+
):
292+
# Op.merge_into is scoped to flat mappings (uniform indent); for
293+
# nested values, add a placeholder then replace via complex-replace
294+
# which preserves relative indentation.
295+
op = _core.Op.add(first_key, None)
296+
patch = _core.Patch(route=route, operation=op)
297+
doc = self._apply_patches([patch])
298+
replace_route = _make_route((*parent_keys, first_key))
299+
replace_op = _core.Op.replace(nested_value)
300+
replace_patch = _core.Patch(route=replace_route, operation=replace_op)
301+
return doc._apply_patches([replace_patch])
302+
elif isinstance(nested_value, dict):
290303
op = _core.Op.merge_into(first_key, nested_value)
291304
else:
292305
op = _core.Op.add(first_key, nested_value)

tests/test_document.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,14 @@ def test_upsert_missing_with_list(self):
409409
doc2 = doc.upsert("items", value=["a", "b"])
410410
assert doc2["items"] == ["a", "b"]
411411

412+
def test_upsert_missing_nested_path_with_dict(self):
413+
"""Nested dict via intermediate keys must not flatten."""
414+
doc = Document("a: 1\n")
415+
doc2 = doc.upsert("parent", "child", value={"x": 1, "y": 2})
416+
assert doc2["parent", "child", "x"] == 1
417+
assert doc2["parent", "child", "y"] == 2
418+
assert doc2["parent", "child"] == {"x": 1, "y": 2}
419+
412420

413421
class TestDocumentRemove:
414422
def test_remove_key(self):

0 commit comments

Comments
 (0)