From 98ffe07f93725237e6b707bad76a769e2e51cd00 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 25 May 2026 10:02:32 +1200 Subject: [PATCH 1/6] fix: route nested dicts through complex-replace in _create_at --- doc/specs/2026-05-13-yamltrip-design.md | 7 +++++-- src/yamltrip/document.py | 14 +++++++++++++- tests/test_document.py | 8 ++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/doc/specs/2026-05-13-yamltrip-design.md b/doc/specs/2026-05-13-yamltrip-design.md index 91ead74..bf635ea 100644 --- a/doc/specs/2026-05-13-yamltrip-design.md +++ b/doc/specs/2026-05-13-yamltrip-design.md @@ -115,8 +115,11 @@ All return a new `Document`. 1. **Full path exists:** delegates to `replace()`. 2. **Partial path exists:** walks down to the deepest existing key, then uses - yamlpatch `MergeInto` to create the remaining nested structure in a single - operation. + yamlpatch `MergeInto` for flat mapping values (scalar-only entries) or a + two-step `Add` placeholder + `Replace` for nested values (dicts/lists inside + the value). `MergeInto` uses uniform-indent serialization that only handles + one nesting level; the `Replace` path routes through complex-replace which + preserves relative indentation at arbitrary depth. 3. **No path exists:** uses yamlpatch `Add` with a nested value to create the entire path in a single operation. diff --git a/src/yamltrip/document.py b/src/yamltrip/document.py index 92f0dec..833db69 100644 --- a/src/yamltrip/document.py +++ b/src/yamltrip/document.py @@ -286,7 +286,19 @@ def _create_at( for k in reversed(child_keys[1:]): nested_value = {k: nested_value} route = _make_route(parent_keys) - if isinstance(nested_value, dict): + if isinstance(nested_value, dict) and any( + isinstance(v, (dict, list, tuple)) for v in nested_value.values() + ): + # Op.merge_into is scoped to flat mappings (uniform indent); for + # nested values, add a placeholder then replace via complex-replace + # which preserves relative indentation. + add_op = _core.Op.add(first_key, None) + add_patch = _core.Patch(route=route, operation=add_op) + replace_route = _make_route((*parent_keys, first_key)) + replace_op = _core.Op.replace(nested_value) + replace_patch = _core.Patch(route=replace_route, operation=replace_op) + return self._apply_patches([add_patch, replace_patch]) + elif isinstance(nested_value, dict): op = _core.Op.merge_into(first_key, nested_value) else: op = _core.Op.add(first_key, nested_value) diff --git a/tests/test_document.py b/tests/test_document.py index 16149d1..25255a7 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -409,6 +409,14 @@ def test_upsert_missing_with_list(self): doc2 = doc.upsert("items", value=["a", "b"]) assert doc2["items"] == ["a", "b"] + def test_upsert_missing_nested_path_with_dict(self): + """Nested dict via intermediate keys must not flatten.""" + doc = Document("a: 1\n") + doc2 = doc.upsert("parent", "child", value={"x": 1, "y": 2}) + assert doc2["parent", "child", "x"] == 1 + assert doc2["parent", "child", "y"] == 2 + assert doc2["parent", "child"] == {"x": 1, "y": 2} + class TestDocumentRemove: def test_remove_key(self): From 5619950ea013b6edf30846dcd1e03f1aa2da5f40 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 25 May 2026 10:03:11 +1200 Subject: [PATCH 2/6] docs: broaden merge_into docstring to cover sequences --- src/ops.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ops.rs b/src/ops.rs index 7701b44..e678dbc 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -71,6 +71,13 @@ impl PyOp { } /// Merge key-value pairs into an existing mapping. + /// + /// The `updates` map must contain flat (scalar) values only. Non-scalar + /// values (for example, nested mappings or sequences) will produce + /// incorrect indentation due to yamlpatch's uniform-indent serialization + /// in `handle_block_mapping_addition`. + /// For nested values, use `Op.add` + `Op.replace` to route through + /// the complex-replace path which preserves relative indentation. #[staticmethod] fn merge_into(key: &str, updates: &Bound<'_, PyAny>) -> PyResult { let dict = updates.cast::().map_err(|_| { From 1682f9f65fbe982119be8349b29833535f244241 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 25 May 2026 10:19:10 +1200 Subject: [PATCH 3/6] feat: reject non-scalar values in merge_into with ValueError Also adds a regression test for list values via intermediate keys. --- src/ops.rs | 10 ++++++++++ tests/test_core_ops.py | 10 ++++++++++ tests/test_document.py | 7 +++++++ 3 files changed, 27 insertions(+) diff --git a/src/ops.rs b/src/ops.rs index e678dbc..e5966a5 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -93,6 +93,16 @@ impl PyOp { for (k, v) in dict.iter() { let key_str: String = k.extract()?; let val = py_to_yaml_value(&v)?; + if matches!( + val, + serde_yaml::Value::Mapping(_) | serde_yaml::Value::Sequence(_) + ) { + return Err(PyErr::new::(format!( + "merge_into requires scalar values, but key '{}' has a non-scalar value; \ + use Op.add + Op.replace for nested values", + key_str + ))); + } map.insert(key_str, val); } Ok(Self { diff --git a/tests/test_core_ops.py b/tests/test_core_ops.py index 66d4c07..8b8ba87 100644 --- a/tests/test_core_ops.py +++ b/tests/test_core_ops.py @@ -1,3 +1,5 @@ +import pytest + from yamltrip._core import Op, Patch, Route, apply_patches @@ -18,6 +20,14 @@ def test_append(self): op = Op.append("item") assert op is not None + def test_merge_into_rejects_nested_dict(self): + with pytest.raises(ValueError, match="merge_into requires scalar values"): + Op.merge_into("key", {"child": {"nested": 1}}) + + def test_merge_into_rejects_nested_list(self): + with pytest.raises(ValueError, match="merge_into requires scalar values"): + Op.merge_into("key", {"items": [1, 2, 3]}) + class TestApplyPatches: def test_replace_value(self): diff --git a/tests/test_document.py b/tests/test_document.py index 25255a7..5d7ee7a 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -417,6 +417,13 @@ def test_upsert_missing_nested_path_with_dict(self): assert doc2["parent", "child", "y"] == 2 assert doc2["parent", "child"] == {"x": 1, "y": 2} + def test_upsert_missing_nested_path_with_list(self): + """Nested list via intermediate keys must not flatten.""" + doc = Document("a: 1\n") + doc2 = doc.upsert("parent", "items", value=["a", "b"]) + assert doc2["parent", "items"] == ["a", "b"] + assert doc2["a"] == 1 + class TestDocumentRemove: def test_remove_key(self): From 724fba11502abd2f989ffbb8e440ec0d4499a0ef Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 25 May 2026 10:29:08 +1200 Subject: [PATCH 4/6] test: add sibling-preservation assertion and clarify matches! comment --- src/ops.rs | 1 + tests/test_document.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/ops.rs b/src/ops.rs index e5966a5..04f390f 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -93,6 +93,7 @@ impl PyOp { for (k, v) in dict.iter() { let key_str: String = k.extract()?; let val = py_to_yaml_value(&v)?; + // matches! with wildcard patterns only inspects; no move occurs. if matches!( val, serde_yaml::Value::Mapping(_) | serde_yaml::Value::Sequence(_) diff --git a/tests/test_document.py b/tests/test_document.py index 5d7ee7a..f3e5081 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -416,6 +416,7 @@ def test_upsert_missing_nested_path_with_dict(self): assert doc2["parent", "child", "x"] == 1 assert doc2["parent", "child", "y"] == 2 assert doc2["parent", "child"] == {"x": 1, "y": 2} + assert doc2["a"] == 1 def test_upsert_missing_nested_path_with_list(self): """Nested list via intermediate keys must not flatten.""" From eba270e6af81c47cf2051e5a89643ad6e57357ef Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 25 May 2026 10:40:54 +1200 Subject: [PATCH 5/6] docs: fix stale spec bullet for no-path upsert strategy --- doc/specs/2026-05-13-yamltrip-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/specs/2026-05-13-yamltrip-design.md b/doc/specs/2026-05-13-yamltrip-design.md index bf635ea..3d4e3bd 100644 --- a/doc/specs/2026-05-13-yamltrip-design.md +++ b/doc/specs/2026-05-13-yamltrip-design.md @@ -120,8 +120,8 @@ All return a new `Document`. the value). `MergeInto` uses uniform-indent serialization that only handles one nesting level; the `Replace` path routes through complex-replace which preserves relative indentation at arbitrary depth. -3. **No path exists:** uses yamlpatch `Add` with a nested value to create the - entire path in a single operation. +3. **No path exists:** routes through the same `_create_at` strategy as (2), + using `MergeInto` for flat mappings or `Add` + `Replace` for nested values. This is implemented in the Python layer using existing yamlpatch operations. From 28b410e77f422c0354f28fb4d50b034d6e9e61c8 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 25 May 2026 10:50:12 +1200 Subject: [PATCH 6/6] chore: expand matches! comment and add maturin dev dep --- pyproject.toml | 1 + src/ops.rs | 5 ++++- uv.lock | 26 ++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6b5a114..bec1cfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dev = [ "codespell>=2.4.2", "deptry>=0.25.1", "import-linter>=2.11", + "maturin>=1.13.3", "prek>=0.3.13", "pyproject-fmt>=2.21.2", "pytest>=9.0.3", diff --git a/src/ops.rs b/src/ops.rs index 04f390f..af2f208 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -93,7 +93,10 @@ impl PyOp { for (k, v) in dict.iter() { let key_str: String = k.extract()?; let val = py_to_yaml_value(&v)?; - // matches! with wildcard patterns only inspects; no move occurs. + // `matches!` with wildcard `_` patterns does not move `val`: the + // expanded `match` only inspects the enum discriminant without + // binding or destructuring inner data, so `val` remains owned and + // usable in `map.insert` below. if matches!( val, serde_yaml::Value::Mapping(_) | serde_yaml::Value::Sequence(_) diff --git a/uv.lock b/uv.lock index cf035b5..64b4c80 100644 --- a/uv.lock +++ b/uv.lock @@ -471,6 +471,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "maturin" +version = "1.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/1c/612d23d33ec21b9ae7ece7b3f0dd5f9dfd57b4009e9d2938165869ebd6ae/maturin-1.13.3.tar.gz", hash = "sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079", size = 357934, upload-time = "2026-05-11T07:43:39.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/66/18c2aaac0b2a5dea9f1db5984ce83b905ad205cfc7c02d0091e707c0c2e7/maturin-1.13.3-py3-none-linux_armv6l.whl", hash = "sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c", size = 10190971, upload-time = "2026-05-11T07:43:10.431Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/26a988d092e4fd6a9523d46d44400a46cad7cdf3fd206ce702240c748aee/maturin-1.13.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323", size = 19716714, upload-time = "2026-05-11T07:43:36.911Z" }, + { url = "https://files.pythonhosted.org/packages/82/5c/f3fd0e184255d9fc7e272c62af3dfa84c617b2577ef83af9ce615f5279cc/maturin-1.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0", size = 10194726, upload-time = "2026-05-11T07:43:07.05Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e1/f4edb69fb647b77c4769a9bfd4d6fb62961e653d164bc277ecdffac3ab61/maturin-1.13.3-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11", size = 10172781, upload-time = "2026-05-11T07:43:40.796Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/a1be934690cdcc3c6609769ceaad322ab7501c2ee5bafcac1b14d609e403/maturin-1.13.3-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f", size = 10682670, upload-time = "2026-05-11T07:43:13.132Z" }, + { url = "https://files.pythonhosted.org/packages/18/f5/372ae19b72ce8f6e37e5864ae4dc5b252ee9fce0619ccc3aa366aa3a7f97/maturin-1.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018", size = 10060363, upload-time = "2026-05-11T07:43:21.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/c68340cca09368af0df80965dfabed4234205a492a93da00793c7b9aae20/maturin-1.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3", size = 10017551, upload-time = "2026-05-11T07:43:33.916Z" }, + { url = "https://files.pythonhosted.org/packages/28/1e/f90fb2b000bad9e6d850cd5afb88b2f1e2a279cfb4de02ea40078484690e/maturin-1.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a", size = 13301712, upload-time = "2026-05-11T07:43:26.492Z" }, + { url = "https://files.pythonhosted.org/packages/be/58/1670f68a8f04ccd7b90df11047bd9a046585310e84e1967cc9849cd1c5a3/maturin-1.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff", size = 10946765, upload-time = "2026-05-11T07:43:16.135Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/00c955c2ef134817b1a7bdaa76b0309e9c5291eb17d9ff88069eecd08bc2/maturin-1.13.3-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273", size = 10388661, upload-time = "2026-05-11T07:43:18.727Z" }, + { url = "https://files.pythonhosted.org/packages/97/c6/cbf8a51dde19c19aeba0d9b075095a2effb9b31fd312b1aae3ac79f8aea2/maturin-1.13.3-py3-none-win32.whl", hash = "sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b", size = 8901838, upload-time = "2026-05-11T07:43:23.76Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ff/c6a50a59dc8313097d43ac5f4d74df6a500c8cb62b0dc9e054f53e203a48/maturin-1.13.3-py3-none-win_amd64.whl", hash = "sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6", size = 10340801, upload-time = "2026-05-11T07:43:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/6c/93/e32e79333f0902ba292b996f504f5f06be59587f7d02ab8d5ed1e3066445/maturin-1.13.3-py3-none-win_arm64.whl", hash = "sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b", size = 9706562, upload-time = "2026-05-11T07:43:31.743Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -824,6 +848,7 @@ dev = [ { name = "codespell" }, { name = "deptry" }, { name = "import-linter" }, + { name = "maturin" }, { name = "prek" }, { name = "pyproject-fmt" }, { name = "pytest" }, @@ -843,6 +868,7 @@ dev = [ { name = "codespell", specifier = ">=2.4.2" }, { name = "deptry", specifier = ">=0.25.1" }, { name = "import-linter", specifier = ">=2.11" }, + { name = "maturin", specifier = ">=1.13.3" }, { name = "prek", specifier = ">=0.3.13" }, { name = "pyproject-fmt", specifier = ">=2.21.2" }, { name = "pytest", specifier = ">=9.0.3" },