From 21543c25eed9c6cea3c12f6f5216c48c2905c702 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Wed, 20 May 2026 09:20:35 +1200 Subject: [PATCH 1/8] docs: add sync operation design spec --- doc/specs/2026-05-20-sync-operation-design.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 doc/specs/2026-05-20-sync-operation-design.md diff --git a/doc/specs/2026-05-20-sync-operation-design.md b/doc/specs/2026-05-20-sync-operation-design.md new file mode 100644 index 0000000..58b9385 --- /dev/null +++ b/doc/specs/2026-05-20-sync-operation-design.md @@ -0,0 +1,168 @@ +# Sync Operation + +**Date:** 2026-05-20 +**Issue:** #25 + +## Problem + +A common pattern when editing YAML config files is: parse into a Python dict, mutate the dict, write it back preserving comments and formatting. With yamltrip today, step 3 requires manually orchestrating individual `upsert`, `replace`, `append`, `remove`, and `remove_from_list` calls for each change. This is verbose and error-prone. + +## Design + +Add a `sync` method that takes a desired Python value, diffs it against the current document content at a given path, and applies the minimal set of patches. + +### Signature + +```python +# Document (immutable — returns new Document) +def sync(self, *keys: KeyPart, value: Any) -> Document: + +# Editor (mutable — updates in place) +def sync(self, *keys: KeyPart, value: Any) -> None: +``` + +### Examples + +```python +# Sync a mapping — adds missing keys, removes extra keys, updates changed values +doc2 = doc.sync('ci', value={'autofix_prs': False, 'skip': ['codespell']}) + +# Sync a list — uses SequenceMatcher for minimal edits +doc2 = doc.sync('repos', value=new_repos) + +# Sync at root +doc2 = doc.sync(value={'ci': {...}, 'repos': [...]}) + +# No-op — returns self when value already matches +doc2 = doc.sync('ci', value=doc['ci']) +assert doc2 is doc +``` + +### Diff Algorithm + +The diff is recursive. At each level, the type of old and new values determines the strategy: + +**Mapping diff** (both old and new are dicts): +1. Keys in new that exist in old → recurse into value pair +2. Keys in new that don't exist in old → `upsert` the new key+value +3. Keys in old that don't exist in new → `remove` the key +4. Key ordering: existing key order is preserved; new keys are appended at the end + +**List diff** (both old and new are lists): +Uses `difflib.SequenceMatcher` to compute an optimal edit script. Items are compared by deep equality (`==`). Since `SequenceMatcher` requires hashable elements, items are mapped to integers via equality comparison (same approach as usethis's `_shared_id_sequences`). + +Opcode translation: +- `equal` → no patch +- `replace` → if both items are dicts, recurse; otherwise `replace` at index +- `insert` → `insert(index=...)` for each new item +- `delete` → `remove` at index (processed in reverse to preserve indices) + +**Scalar diff** (values differ or types differ): +- Single `replace` at the path + +**Type mismatch** (e.g. old is mapping, new is scalar): +- Single `replace` at the path (no error) + +### Patch Application Order + +Patches are applied sequentially — each patch operates on the result of the previous one. For list diffs, opcodes are processed left-to-right with a running position offset that accounts for how prior inserts/deletes have shifted indices. This mirrors how usethis's `lcs_list_update` maintains an `original_idx` cursor. + +For mapping diffs and scalar replacements, order doesn't matter (no index shifting). + +### Edge Cases + +| Scenario | Behavior | +|---|---| +| Path doesn't exist in document | `upsert` the whole value (creates path) | +| Type mismatch at path | `replace` the whole node | +| `value` is `{}` | Remove all keys from mapping at path | +| `value` is `[]` | Remove all items from sequence at path | +| `value` is `None` | Replace with YAML null | +| Value already matches | Return `self` (no patches, identity preserved) | +| Root-level sync (empty keys) | Diffs the entire document root | + +### No-op Optimization + +When the diff produces zero patches, `sync` returns `self` unchanged. This allows callers to detect "no changes needed" via `doc.sync(...) is doc`. + +## Module Structure + +New file: `src/yamltrip/sync.py` + +Contains the pure diff logic: + +```python +def _compute_patches( + old_value: Any, + new_value: Any, + path: tuple[KeyPart, ...], +) -> list[_core.Patch]: +``` + +This function is called by `Document.sync()` which: +1. Checks if the path exists — if not, delegates to `upsert` +2. Gets current value via `self[keys]` +3. Calls `_compute_patches(old, new, keys)` +4. If no patches, returns `self` +5. Otherwise, applies patches via `self._apply_patches(patches)` + +## Change Locations + +- `src/yamltrip/sync.py` — new module with `_compute_patches` and helpers +- `src/yamltrip/document.py` — add `sync()` method to `Document` +- `src/yamltrip/editor.py` — add `sync()` method to `Editor` +- No Rust changes required + +## Testing + +### Mapping sync tests +- Add a new key to a mapping +- Remove a key from a mapping +- Change a scalar value in a mapping +- Nested mapping: change a deep value +- Nested mapping: add a deep key +- Sync preserves comments on unchanged keys +- Sync preserves comments on keys with changed values +- Empty dict `{}` removes all mapping keys + +### List sync tests +- Append items to a list +- Remove items from a list +- Replace an item in a list (scalar) +- Insert an item in the middle +- Reorder items (delete + insert) +- List of dicts: changed dict is recursed into (comments preserved) +- Empty list `[]` removes all items +- Preserves comments on unchanged list items + +### Type mismatch tests +- Old is mapping, new is scalar → replaces +- Old is scalar, new is mapping → replaces +- Old is list, new is scalar → replaces + +### Path handling tests +- Path doesn't exist → creates via upsert +- Root-level sync (empty keys) +- Nested path with intermediate keys + +### No-op tests +- Value matches exactly → returns `self` +- Editor: no-op sync doesn't modify file + +### Integration tests +- Pre-commit-style config: add a repo, modify a hook, remove a repo +- Multi-level nested change in one sync call + +## Scope Boundaries + +**In scope:** +- `Document.sync()` method +- `Editor.sync()` method +- New `sync.py` module with diff logic +- Tests for all the above + +**Out of scope:** +- `key` parameter for identity-based list matching (future PR) +- Key reordering within mappings +- Stub file changes — this is pure Python +- Performance optimization for large documents From df228c8843788eb0b076aef81d2192d206332ffb Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Wed, 20 May 2026 09:33:51 +1200 Subject: [PATCH 2/8] feat: add sync method with mapping diff support --- .importlinter | 2 + src/yamltrip/document.py | 27 ++++++++++++++ src/yamltrip/sync.py | 79 ++++++++++++++++++++++++++++++++++++++++ tests/test_sync.py | 44 ++++++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 src/yamltrip/sync.py create mode 100644 tests/test_sync.py diff --git a/.importlinter b/.importlinter index 55d641b..6357a1e 100644 --- a/.importlinter +++ b/.importlinter @@ -8,6 +8,7 @@ type = layers layers = editor document + sync errors containers = yamltrip @@ -16,3 +17,4 @@ exhaustive_ignores = _core ignore_imports = yamltrip.document -> yamltrip + yamltrip.sync -> yamltrip diff --git a/src/yamltrip/document.py b/src/yamltrip/document.py index 61280fe..696f2c5 100644 --- a/src/yamltrip/document.py +++ b/src/yamltrip/document.py @@ -325,3 +325,30 @@ def remove_from_list(self, *keys: KeyPart, values: Sequence[Any]) -> Document: for idx in indices_to_remove ] return self._apply_patches(patches) + + def sync(self, *keys: KeyPart, value: Any) -> Document: + """Sync the value at path to match the desired value. + + Diffs the current value against the desired value and applies + the minimal set of patches. Returns self if no changes needed. + """ + from yamltrip.sync import _compute_patches # noqa: PLC0415 + + normalized = _normalize_keys(keys) if keys else () + + # If path doesn't exist, delegate to upsert + if normalized: + route = _make_route(normalized) + if not self._core_doc.query_exists(route): + return self.upsert(*normalized, value=value) + + # Get current value and diff + try: + old_value = self._core_doc.parse_value(_make_route(normalized)) + except (ValueError, KeyError): + return self.upsert(*normalized, value=value) + + patches = _compute_patches(old_value, value, normalized) + if not patches: + return self + return self._apply_patches(patches) diff --git a/src/yamltrip/sync.py b/src/yamltrip/sync.py new file mode 100644 index 0000000..a14c080 --- /dev/null +++ b/src/yamltrip/sync.py @@ -0,0 +1,79 @@ +"""Diff logic for the sync operation.""" + +from __future__ import annotations + +from typing import Any, TypeAlias + +from yamltrip import _core + +KeyPart: TypeAlias = "str | int" + + +def _compute_patches( + old_value: Any, + new_value: Any, + path: tuple[KeyPart, ...], +) -> list[_core.Patch]: + """Compute minimal patches to transform old_value into new_value at path.""" + if old_value == new_value: + return [] + + old_is_dict = isinstance(old_value, dict) + new_is_dict = isinstance(new_value, dict) + + if old_is_dict and new_is_dict: + return _diff_mappings(old_value, new_value, path) + + old_is_list = isinstance(old_value, list) + new_is_list = isinstance(new_value, list) + + if old_is_list and new_is_list: + return _diff_lists(old_value, new_value, path) + + # Type mismatch or scalar change — replace + route = _core.Route(list(path)) + op = _core.Op.replace(new_value) + return [_core.Patch(route=route, operation=op)] + + +def _diff_mappings( + old: dict[str, Any], + new: dict[str, Any], + path: tuple[KeyPart, ...], +) -> list[_core.Patch]: + """Diff two mappings and return patches.""" + patches: list[_core.Patch] = [] + + # Keys in new that exist in old — recurse + for key in new: + if key in old: + child_patches = _compute_patches(old[key], new[key], (*path, key)) + patches.extend(child_patches) + else: + # New key — add + route = _core.Route(list(path)) + op = _core.Op.add(key, new[key]) + patches.append(_core.Patch(route=route, operation=op)) + + # Keys in old not in new — remove + for key in old: + if key not in new: + route = _core.Route([*path, key]) + op = _core.Op.remove() + patches.append(_core.Patch(route=route, operation=op)) + + return patches + + +def _diff_lists( + _old: list[Any], + new: list[Any], + path: tuple[KeyPart, ...], +) -> list[_core.Patch]: + """Diff two lists using SequenceMatcher and return patches. + + Placeholder — will be fully implemented in Task 2. + """ + route = _core.Route(list(path)) + op = _core.Op.replace(new) + return [_core.Patch(route=route, operation=op)] diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..d2f206d --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,44 @@ +from yamltrip.document import Document + + +class TestSyncMappingAddKey: + def test_adds_missing_key(self): + doc = Document("a: 1\n") + doc2 = doc.sync(value={"a": 1, "b": 2}) + assert doc2["b"] == 2 + assert doc2["a"] == 1 + + +class TestSyncMappingRemoveKey: + def test_removes_extra_key(self): + doc = Document("a: 1\nb: 2\n") + doc2 = doc.sync(value={"a": 1}) + assert doc2["a"] == 1 + assert ("b",) not in doc2 + + +class TestSyncMappingChangeScalar: + def test_changes_scalar_value(self): + doc = Document("a: 1\nb: 2\n") + doc2 = doc.sync(value={"a": 1, "b": 99}) + assert doc2["b"] == 99 + assert doc2["a"] == 1 + + +class TestSyncMappingNested: + def test_changes_nested_value(self): + doc = Document("top:\n a: 1\n b: 2\n") + doc2 = doc.sync("top", value={"a": 1, "b": 99}) + assert doc2["top", "b"] == 99 + + def test_adds_nested_key(self): + doc = Document("top:\n a: 1\n") + doc2 = doc.sync("top", value={"a": 1, "b": 2}) + assert doc2["top", "b"] == 2 + + +class TestSyncNoop: + def test_returns_self_when_no_change(self): + doc = Document("a: 1\nb: 2\n") + doc2 = doc.sync(value={"a": 1, "b": 2}) + assert doc2 is doc From 2098ff083d8e5d512d062c6320c3a1e4811c1a3b Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Wed, 20 May 2026 09:37:46 +1200 Subject: [PATCH 3/8] feat: add SequenceMatcher-based list diffing to sync --- src/yamltrip/sync.py | 116 ++++++++++++++++++++++++++++++++++++++++--- tests/test_sync.py | 47 ++++++++++++++++++ 2 files changed, 156 insertions(+), 7 deletions(-) diff --git a/src/yamltrip/sync.py b/src/yamltrip/sync.py index a14c080..94b521f 100644 --- a/src/yamltrip/sync.py +++ b/src/yamltrip/sync.py @@ -2,6 +2,7 @@ from __future__ import annotations +from difflib import SequenceMatcher from typing import Any, TypeAlias from yamltrip import _core @@ -66,14 +67,115 @@ def _diff_mappings( def _diff_lists( - _old: list[Any], + old: list[Any], new: list[Any], path: tuple[KeyPart, ...], ) -> list[_core.Patch]: - """Diff two lists using SequenceMatcher and return patches. + """Diff two lists using SequenceMatcher and return patches.""" + if not old and not new: + return [] - Placeholder — will be fully implemented in Task 2. - """ - route = _core.Route(list(path)) - op = _core.Op.replace(new) - return [_core.Patch(route=route, operation=op)] + # Map items to integers for SequenceMatcher (handles unhashable items) + int_old, int_new = _shared_int_sequences(old, new) + + sm = SequenceMatcher(None, int_old, int_new, autojunk=False) + patches: list[_core.Patch] = [] + + # Track offset: as inserts/deletes happen, indices in the original shift + offset = 0 + + for tag, i1, i2, j1, j2 in sm.get_opcodes(): + if tag == "equal": + continue + elif tag == "replace": + replace_patches, offset = _apply_replace( + old, new, path, (i1, i2, j1, j2), offset + ) + patches.extend(replace_patches) + elif tag == "insert": + for k in range(j1, j2): + insert_idx = i1 + offset + route = _core.Route(list(path)) + insert_op = _core.Op.insert_at(index=insert_idx, value=new[k]) + patches.append(_core.Patch(route=route, operation=insert_op)) + offset += 1 + elif tag == "delete": + # Remove from highest index to lowest within this block + for k in reversed(range(i1, i2)): + idx = k + offset + route = _core.Route([*path, idx]) + remove_op = _core.Op.remove() + patches.append(_core.Patch(route=route, operation=remove_op)) + offset -= 1 + + return patches + + +def _apply_replace( + old: list[Any], + new: list[Any], + path: tuple[KeyPart, ...], + opcode: tuple[int, int, int, int], + offset: int, +) -> tuple[list[_core.Patch], int]: + """Handle a 'replace' opcode block, returning patches and updated offset.""" + i1, i2, j1, j2 = opcode + replace_count = min(i2 - i1, j2 - j1) + patches: list[_core.Patch] = [] + for k in range(replace_count): + old_idx = i1 + k + offset + child_path = (*path, old_idx) + if isinstance(old[i1 + k], dict) and isinstance(new[j1 + k], dict): + child_patches = _compute_patches(old[i1 + k], new[j1 + k], child_path) + patches.extend(child_patches) + else: + route = _core.Route(list(child_path)) + replace_op = _core.Op.replace(new[j1 + k]) + patches.append(_core.Patch(route=route, operation=replace_op)) + + # More new items than old — insert the extras + for k in range(replace_count, j2 - j1): + insert_idx = i1 + replace_count + offset + route = _core.Route(list(path)) + insert_op = _core.Op.insert_at(index=insert_idx, value=new[j1 + k]) + patches.append(_core.Patch(route=route, operation=insert_op)) + offset += 1 + + # More old items than new — remove the extras (reverse order) + remove_indices = [i1 + k + offset for k in range(replace_count, i2 - i1)] + for idx in reversed(remove_indices): + route = _core.Route([*path, idx]) + remove_op = _core.Op.remove() + patches.append(_core.Patch(route=route, operation=remove_op)) + offset -= 1 + + return patches, offset + + +def _shared_int_sequences( + old: list[Any], new: list[Any] +) -> tuple[list[int], list[int]]: + """Map list elements to integers equal iff the objects compare equal.""" + rep: list[Any] = [] + int_old: list[int] = [] + int_new: list[int] = [] + + for item in old: + for idx, rep_item in enumerate(rep): + if item == rep_item: + int_old.append(idx) + break + else: + int_old.append(len(rep)) + rep.append(item) + + for item in new: + for idx, rep_item in enumerate(rep): + if item == rep_item: + int_new.append(idx) + break + else: + int_new.append(len(rep)) + rep.append(item) + + return int_old, int_new diff --git a/tests/test_sync.py b/tests/test_sync.py index d2f206d..185ae8a 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -42,3 +42,50 @@ def test_returns_self_when_no_change(self): doc = Document("a: 1\nb: 2\n") doc2 = doc.sync(value={"a": 1, "b": 2}) assert doc2 is doc + + +class TestSyncListAppend: + def test_appends_new_items(self): + doc = Document("items:\n - a\n - b\n") + doc2 = doc.sync("items", value=["a", "b", "c"]) + assert doc2["items"] == ["a", "b", "c"] + + +class TestSyncListRemove: + def test_removes_items(self): + doc = Document("items:\n - a\n - b\n - c\n") + doc2 = doc.sync("items", value=["a", "c"]) + assert doc2["items"] == ["a", "c"] + + +class TestSyncListReplace: + def test_replaces_scalar_item(self): + doc = Document("items:\n - a\n - b\n - c\n") + doc2 = doc.sync("items", value=["a", "x", "c"]) + assert doc2["items"] == ["a", "x", "c"] + + +class TestSyncListInsert: + def test_inserts_in_middle(self): + doc = Document("items:\n - a\n - c\n") + doc2 = doc.sync("items", value=["a", "b", "c"]) + assert doc2["items"] == ["a", "b", "c"] + + +class TestSyncListNoop: + def test_no_change_returns_self(self): + doc = Document("items:\n - a\n - b\n") + doc2 = doc.sync("items", value=["a", "b"]) + assert doc2 is doc + + +class TestSyncListOfDicts: + def test_recurses_into_matching_dicts(self): + doc = Document( + "repos:\n - repo: foo\n rev: v1\n - repo: bar\n rev: v2\n" + ) + doc2 = doc.sync( + "repos", value=[{"repo": "foo", "rev": "v1"}, {"repo": "bar", "rev": "v3"}] + ) + assert doc2["repos", 1, "rev"] == "v3" + assert doc2["repos", 0, "rev"] == "v1" From a3cef646b8cc6b2afb45fdb5ab020d408b44623e Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Wed, 20 May 2026 09:38:59 +1200 Subject: [PATCH 4/8] test: add comment preservation tests for sync --- tests/test_sync.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_sync.py b/tests/test_sync.py index 185ae8a..2a1d95c 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -89,3 +89,25 @@ def test_recurses_into_matching_dicts(self): ) assert doc2["repos", 1, "rev"] == "v3" assert doc2["repos", 0, "rev"] == "v1" + + +class TestSyncPreservesComments: + def test_mapping_comment_preserved_on_unchanged_key(self): + source = "# top comment\na: 1\n# b comment\nb: 2\n" + doc = Document(source) + doc2 = doc.sync(value={"a": 99, "b": 2}) + assert "# b comment" in doc2.source + assert "# top comment" in doc2.source + + def test_mapping_comment_preserved_on_changed_key(self): + source = "# a comment\na: 1\n# b comment\nb: 2\n" + doc = Document(source) + doc2 = doc.sync(value={"a": 1, "b": 99}) + assert "# b comment" in doc2.source + + def test_list_comment_preserved_on_unchanged_item(self): + source = "items:\n # first\n - a\n # second\n - b\n # third\n - c\n" + doc = Document(source) + doc2 = doc.sync("items", value=["a", "x", "c"]) + assert "# first" in doc2.source + assert "# third" in doc2.source From 19387af836c55803b0b76d929555bd876fbb6252 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Wed, 20 May 2026 09:41:48 +1200 Subject: [PATCH 5/8] test: add edge case and path handling tests for sync --- src/yamltrip/sync.py | 8 +++++- tests/test_sync.py | 63 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/yamltrip/sync.py b/src/yamltrip/sync.py index 94b521f..8c4daaa 100644 --- a/src/yamltrip/sync.py +++ b/src/yamltrip/sync.py @@ -75,6 +75,12 @@ def _diff_lists( if not old and not new: return [] + # Replacing with empty list: use a single replace instead of removing items + if not new: + route = _core.Route(list(path)) + op = _core.Op.replace([]) + return [_core.Patch(route=route, operation=op)] + # Map items to integers for SequenceMatcher (handles unhashable items) int_old, int_new = _shared_int_sequences(old, new) @@ -106,7 +112,7 @@ def _diff_lists( route = _core.Route([*path, idx]) remove_op = _core.Op.remove() patches.append(_core.Patch(route=route, operation=remove_op)) - offset -= 1 + offset -= i2 - i1 return patches diff --git a/tests/test_sync.py b/tests/test_sync.py index 2a1d95c..09fbcea 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -111,3 +111,66 @@ def test_list_comment_preserved_on_unchanged_item(self): doc2 = doc.sync("items", value=["a", "x", "c"]) assert "# first" in doc2.source assert "# third" in doc2.source + + +class TestSyncPathNotExists: + def test_creates_path_via_upsert(self): + doc = Document("a: 1\n") + doc2 = doc.sync("b", value=2) + assert doc2["b"] == 2 + assert doc2["a"] == 1 + + def test_creates_nested_path(self): + doc = Document("a: 1\n") + doc2 = doc.sync("b", "c", value=3) + assert doc2["b", "c"] == 3 + + +class TestSyncTypeMismatch: + def test_mapping_to_scalar(self): + doc = Document("a:\n x: 1\n y: 2\n") + doc2 = doc.sync("a", value="hello") + assert doc2["a"] == "hello" + + def test_scalar_to_mapping(self): + doc = Document("a: hello\n") + doc2 = doc.sync("a", value={"x": 1}) + assert doc2["a"] == {"x": 1} + + def test_list_to_scalar(self): + doc = Document("a:\n - 1\n - 2\n") + doc2 = doc.sync("a", value="flat") + assert doc2["a"] == "flat" + + +class TestSyncRootLevel: + def test_sync_entire_root(self): + doc = Document("a: 1\nb: 2\n") + doc2 = doc.sync(value={"a": 1, "b": 2, "c": 3}) + assert doc2["c"] == 3 + + def test_sync_root_remove_key(self): + doc = Document("a: 1\nb: 2\n") + doc2 = doc.sync(value={"a": 1}) + assert ("b",) not in doc2 + + +class TestSyncEmptyValues: + def test_sync_empty_dict_removes_all_keys(self): + doc = Document("a: 1\nb: 2\n") + doc2 = doc.sync(value={}) + # After removing all keys, neither key should exist + assert ("a",) not in doc2 + assert ("b",) not in doc2 + + def test_sync_empty_list_removes_all_items(self): + doc = Document("items:\n - a\n - b\n") + doc2 = doc.sync("items", value=[]) + assert doc2["items"] == [] + + +class TestSyncNullValue: + def test_sync_to_none(self): + doc = Document("a: 1\n") + doc2 = doc.sync("a", value=None) + assert doc2["a"] is None From 31c159cf5a566389fb220bff4d627ff084ccd7d7 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Wed, 20 May 2026 09:44:35 +1200 Subject: [PATCH 6/8] feat: add sync method to Editor --- src/yamltrip/editor.py | 4 ++++ tests/test_sync.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/yamltrip/editor.py b/src/yamltrip/editor.py index e3324ba..5851009 100644 --- a/src/yamltrip/editor.py +++ b/src/yamltrip/editor.py @@ -141,6 +141,10 @@ def remove_from_list(self, *keys: KeyPart, values: Sequence[Any]) -> None: """Remove all occurrences of given values from the sequence at path.""" self._document = self.document.remove_from_list(*keys, values=values) + def sync(self, *keys: KeyPart, value: Any) -> None: + """Sync the value at path to match the desired value.""" + self._document = self.document.sync(*keys, value=value) + def query(self, *keys: KeyPart) -> Feature: """Return the Feature at the given path.""" return self.document.query(*keys) diff --git a/tests/test_sync.py b/tests/test_sync.py index 09fbcea..58e80f0 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -1,4 +1,5 @@ from yamltrip.document import Document +from yamltrip.editor import Editor class TestSyncMappingAddKey: @@ -174,3 +175,30 @@ def test_sync_to_none(self): doc = Document("a: 1\n") doc2 = doc.sync("a", value=None) assert doc2["a"] is None + + +class TestEditorSync: + def test_sync_mapping(self, tmp_path): + p = tmp_path / "test.yml" + p.write_text("a: 1\nb: 2\n", encoding="utf-8") + with Editor(p) as ed: + ed.sync(value={"a": 1, "b": 99, "c": 3}) + content = p.read_text(encoding="utf-8") + assert "b: 99" in content + assert "c: 3" in content + + def test_sync_noop_preserves_content(self, tmp_path): + p = tmp_path / "test.yml" + p.write_text("a: 1\nb: 2\n", encoding="utf-8") + with Editor(p) as ed: + ed.sync(value={"a": 1, "b": 2}) + content = p.read_text(encoding="utf-8") + assert content == "a: 1\nb: 2\n" + + def test_sync_with_path(self, tmp_path): + p = tmp_path / "test.yml" + p.write_text("top:\n a: 1\n", encoding="utf-8") + with Editor(p) as ed: + ed.sync("top", value={"a": 2}) + content = p.read_text(encoding="utf-8") + assert "a: 2" in content From 718dbd0b1534d3c1fc773117936eb643a20ae5cc Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Wed, 20 May 2026 09:45:52 +1200 Subject: [PATCH 7/8] test: add integration tests for sync operation --- tests/test_sync.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_sync.py b/tests/test_sync.py index 58e80f0..2f727ce 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -202,3 +202,52 @@ def test_sync_with_path(self, tmp_path): ed.sync("top", value={"a": 2}) content = p.read_text(encoding="utf-8") assert "a: 2" in content + + +class TestSyncIntegration: + def test_precommit_style_config(self): + source = ( + "repos:\n" + " # Formatting\n" + " - repo: https://github.com/pre-commit/mirrors-prettier\n" + " rev: v3.0.0\n" + " hooks:\n" + " - id: prettier\n" + " # Linting\n" + " - repo: https://github.com/astral-sh/ruff-pre-commit\n" + " rev: v0.4.0\n" + " hooks:\n" + " - id: ruff\n" + " args: [--fix]\n" + ) + doc = Document(source) + + new_repos = [ + { + "repo": "https://github.com/pre-commit/mirrors-prettier", + "rev": "v3.0.0", + "hooks": [{"id": "prettier"}], + }, + { + "repo": "https://github.com/astral-sh/ruff-pre-commit", + "rev": "v0.5.0", # version bump + "hooks": [{"id": "ruff", "args": ["--fix"]}], + }, + ] + + doc2 = doc.sync("repos", value=new_repos) + + # Version was bumped + assert doc2["repos", 1, "rev"] == "v0.5.0" + # First repo unchanged + assert doc2["repos", 0, "rev"] == "v3.0.0" + # Comments preserved + assert "# Formatting" in doc2.source + assert "# Linting" in doc2.source + + def test_multi_level_sync(self): + source = "ci:\n autofix_prs: true\n skip:\n - codespell\n - ruff\n" + doc = Document(source) + doc2 = doc.sync("ci", value={"autofix_prs": False, "skip": ["codespell"]}) + assert doc2["ci", "autofix_prs"] is False + assert doc2["ci", "skip"] == ["codespell"] From 570f85b1972288836ce9fca1965e259dd2cbb519 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Wed, 20 May 2026 10:17:27 +1200 Subject: [PATCH 8/8] refactor: extract KeyPart to _types.py, add clarifying comments --- .importlinter | 1 + src/yamltrip/_types.py | 7 +++++++ src/yamltrip/document.py | 5 +++-- src/yamltrip/editor.py | 2 +- src/yamltrip/sync.py | 7 +++++-- 5 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 src/yamltrip/_types.py diff --git a/.importlinter b/.importlinter index 6357a1e..b5704e4 100644 --- a/.importlinter +++ b/.importlinter @@ -15,6 +15,7 @@ containers = exhaustive = True exhaustive_ignores = _core + _types ignore_imports = yamltrip.document -> yamltrip yamltrip.sync -> yamltrip diff --git a/src/yamltrip/_types.py b/src/yamltrip/_types.py new file mode 100644 index 0000000..f03860f --- /dev/null +++ b/src/yamltrip/_types.py @@ -0,0 +1,7 @@ +"""Shared type aliases for yamltrip.""" + +from __future__ import annotations + +from typing import TypeAlias + +KeyPart: TypeAlias = "str | int" diff --git a/src/yamltrip/document.py b/src/yamltrip/document.py index 696f2c5..1b0a04c 100644 --- a/src/yamltrip/document.py +++ b/src/yamltrip/document.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from collections.abc import Sequence -KeyPart = str | int + from yamltrip._types import KeyPart def _normalize_keys(keys: object) -> tuple[KeyPart, ...]: @@ -336,7 +336,8 @@ def sync(self, *keys: KeyPart, value: Any) -> Document: normalized = _normalize_keys(keys) if keys else () - # If path doesn't exist, delegate to upsert + # If path doesn't exist, delegate to upsert. + # Root (empty keys) always exists, so skip the check. if normalized: route = _make_route(normalized) if not self._core_doc.query_exists(route): diff --git a/src/yamltrip/editor.py b/src/yamltrip/editor.py index 5851009..d7730e4 100644 --- a/src/yamltrip/editor.py +++ b/src/yamltrip/editor.py @@ -12,7 +12,7 @@ from types import TracebackType from yamltrip._core import Feature - from yamltrip.document import KeyPart + from yamltrip._types import KeyPart class Editor: diff --git a/src/yamltrip/sync.py b/src/yamltrip/sync.py index 8c4daaa..def0f35 100644 --- a/src/yamltrip/sync.py +++ b/src/yamltrip/sync.py @@ -3,11 +3,12 @@ from __future__ import annotations from difflib import SequenceMatcher -from typing import Any, TypeAlias +from typing import TYPE_CHECKING, Any from yamltrip import _core -KeyPart: TypeAlias = "str | int" +if TYPE_CHECKING: + from yamltrip._types import KeyPart def _compute_patches( @@ -99,6 +100,8 @@ def _diff_lists( ) patches.extend(replace_patches) elif tag == "insert": + # Indices assume patches are applied sequentially (each insert + # shifts subsequent positions). This matches _core.apply_patches. for k in range(j1, j2): insert_idx = i1 + offset route = _core.Route(list(path))