Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions doc/specs/2026-05-13-yamltrip-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,13 @@ 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.
3. **No path exists:** uses yamlpatch `Add` with a nested value to create the
entire path 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:** 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.

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
nathanjmcdougall marked this conversation as resolved.
#[staticmethod]
fn merge_into(key: &str, updates: &Bound<'_, PyAny>) -> PyResult<Self> {
let dict = updates.cast::<pyo3::types::PyDict>().map_err(|_| {
Expand All @@ -86,6 +93,20 @@ 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 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,
Comment thread
nathanjmcdougall marked this conversation as resolved.
serde_yaml::Value::Mapping(_) | serde_yaml::Value::Sequence(_)
) {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(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);
}
Comment thread
nathanjmcdougall marked this conversation as resolved.
Ok(Self {
Expand Down
14 changes: 13 additions & 1 deletion src/yamltrip/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions tests/test_core_ops.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from yamltrip._core import Op, Patch, Route, apply_patches


Expand All @@ -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):
Expand Down
16 changes: 16 additions & 0 deletions tests/test_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,22 @@ 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}
Comment thread
nathanjmcdougall marked this conversation as resolved.
assert doc2["a"] == 1

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


Comment thread
nathanjmcdougall marked this conversation as resolved.
class TestDocumentRemove:
def test_remove_key(self):
Expand Down
26 changes: 26 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading