From f89c1f7ef0eba634d5685fb2fdc457bd0c9e2a21 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 22 May 2026 08:20:22 +1200 Subject: [PATCH 01/11] docs: add design spec for ensure_in_list --- doc/specs/2026-05-22-ensure-in-list-design.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 doc/specs/2026-05-22-ensure-in-list-design.md diff --git a/doc/specs/2026-05-22-ensure-in-list-design.md b/doc/specs/2026-05-22-ensure-in-list-design.md new file mode 100644 index 0000000..d836f4b --- /dev/null +++ b/doc/specs/2026-05-22-ensure-in-list-design.md @@ -0,0 +1,176 @@ +# Design: `ensure_in_list` — Idempotent List Addition + +**Date:** 2026-05-22 +**Status:** Approved + +## Problem + +"Add this value to the list if it's not already there" requires manual +check-then-append boilerplate: + +```python +items = doc["items"] +if "new_hook" not in items: + doc = doc.append("items", value="new_hook") +``` + +This is the most common pattern when managing hook lists, dependency arrays, +and plugin registrations. + +## API + +### Document (immutable, returns new Document) + +```python +def ensure_in_list( + self, + *keys: KeyPart, + value: Any, + where: dict[str, Any] | None = None, +) -> Document: +``` + +### Editor (mutable, returns None) + +```python +def ensure_in_list( + self, + *keys: KeyPart, + value: Any, + where: dict[str, Any] | None = None, +) -> None: +``` + +## Semantics + +### Scalar matching (`where=None`) + +Check if `value` is already in the list via Python `==`. No-op if present, +append if not. + +```python +doc = doc.ensure_in_list("hooks", value="pre-commit") +# If "pre-commit" already in doc["hooks"], returns self unchanged. +# Otherwise appends it. +``` + +### Dict matching (`where={...}`) + +Check if any item in the list has all key/value pairs in `where` (AND +semantics, equality via Python `==`). No-op if a match exists, append `value` +if not. + +The matched item is NOT updated — `ensure_in_list` guarantees presence, not +correctness. Users who need update-in-place should use `find_index` + `replace` +or `sync`. + +```python +doc = doc.ensure_in_list( + "repos", + where={"repo": "https://github.com/pre-commit/mirrors-prettier"}, + value={ + "repo": "https://github.com/pre-commit/mirrors-prettier", + "rev": "v3.0.0", + "hooks": [{"id": "prettier"}], + }, +) +``` + +### Path missing — create the list + +If the path does not exist, create intermediate mappings (like `upsert`) and +set the value to `[value]`. + +```python +doc = doc.ensure_in_list("new_list", value="first_item") +# Result: new_list:\n- first_item +``` + +### Path exists but is not a list + +Raise `NodeTypeError`. + +### Flow sequence fallback + +Same pattern as `append`: catch the flow-sequence error from yamlpatch and fall +back to reading the current list, checking membership in Python, and replacing +with the new list if needed. + +### Idempotence + +Calling `ensure_in_list` twice with the same arguments always produces the same +result. This is guaranteed by the no-op-if-present semantics. + +## What this does NOT include + +- **No `values` plural parameter.** Caller loops for multiple items. This + avoids ambiguity about `where` + `values` interaction. +- **No update-in-place.** If `where` matches an existing item, returns self + unchanged regardless of whether `value` differs from the matched item. +- **No `upsert_in_list`.** Deferred until demand appears. + +## Error conditions + +| Condition | Behavior | +|-----------|----------| +| Path missing | Create list with `[value]` | +| Path is not a list | Raise `NodeTypeError` | +| `where` is empty dict | Raise `ValueError` (matches `find_index`) | +| `where` provided but list items aren't dicts | No match found → append | + +## Implementation approach + +Pure Python in `document.py`. No Rust changes needed — this composes existing +primitives (`__contains__`, `__getitem__`, `append`, `upsert`). + +Rough logic: + +```python +def ensure_in_list(self, *keys, value, where=None): + if where is not None: + if not where: + raise ValueError("where must be a non-empty dict") + + # Check if path exists + route = _make_route(keys) + if not self._core_doc.query_exists(route): + # Path missing: create with [value] + return self.upsert(*keys, value=[value]) + + current = self[keys] + if not isinstance(current, list): + msg = f"Value at {keys} is not a list" + raise NodeTypeError(msg) + + # Check membership + if where is None: + if value in current: + return self + else: + for item in current: + if isinstance(item, dict) and all( + k in item and item[k] == v for k, v in where.items() + ): + return self + + # Not present: append + return self.append(*keys, value=value) +``` + +## Documentation + +Add an example to README.md showing both the scalar and `where`-based usage. + +## Testing + +- Scalar value already present → no-op (same source) +- Scalar value missing → appended +- Dict matching via `where` already present → no-op +- Dict matching via `where` missing → appended +- Path does not exist → list created +- Path is a scalar → `NodeTypeError` +- Path is a mapping → `NodeTypeError` +- Empty `where` dict → `ValueError` +- Flow sequence handling (inline `[a, b]` style) +- Idempotence: calling twice gives same result +- Editor wrapper delegates correctly From a3771a16b8433118f22a77028fbd882468f1072c Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 22 May 2026 08:27:20 +1200 Subject: [PATCH 02/11] feat: add ensure_in_list with scalar matching to Document --- src/yamltrip/document.py | 45 ++++++++++++++++++++++++++++++++++++++++ tests/test_document.py | 33 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/yamltrip/document.py b/src/yamltrip/document.py index 8e3dd68..329bbc1 100644 --- a/src/yamltrip/document.py +++ b/src/yamltrip/document.py @@ -438,6 +438,51 @@ def remove_from_list(self, *keys: KeyPart, values: Sequence[Any]) -> Document: ] return self._apply_patches(patches) + def ensure_in_list( + self, *keys: KeyPart, value: Any, where: dict[str, Any] | None = None + ) -> Document: + """Ensure a value is present in the sequence at path. + + If the value is already present, returns self unchanged (no-op). + If the path does not exist, creates the list with [value]. + Uses Python == for equality checks. + + Args: + *keys: Path to the sequence within the document. + value: The value to ensure is in the list. + where: Optional dict of key/value pairs for matching dicts in + the list (AND semantics). If provided, checks whether any + list item matches all pairs; if so, returns self unchanged. + + Raises: + NodeTypeError: If the value at path is not a list. + ValueError: If where is an empty dict. + """ + if where is not None and not where: + msg = "where must be a non-empty dict" + raise ValueError(msg) + + route = _make_route(keys) + if not self._core_doc.query_exists(route): + return self.upsert(*keys, value=[value]) + + current = self[keys] + if not isinstance(current, list): + msg = f"Value at {keys} is not a list" + raise NodeTypeError(msg) + + if where is None: + if value in current: + return self + else: + for item in current: + if isinstance(item, dict) and all( + k in item and item[k] == v for k, v in where.items() + ): + return self + + return self.append(*keys, value=value) + def sync(self, *keys: KeyPart, value: Any) -> Document: """Sync the value at path to match the desired value. diff --git a/tests/test_document.py b/tests/test_document.py index 87fe123..5e88bef 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -745,3 +745,36 @@ def test_root_upsert_on_empty_raises_patch_error(self): PatchError, match="Cannot replace root of an empty document" ): doc.upsert(value=42) + + +class TestEnsureInList: + def test_scalar_already_present_noop(self): + doc = Document("items:\n - a\n - b\n") + result = doc.ensure_in_list("items", value="a") + assert result.source == doc.source + + def test_scalar_missing_appends(self): + doc = Document("items:\n - a\n - b\n") + result = doc.ensure_in_list("items", value="c") + assert result["items"] == ["a", "b", "c"] + + def test_integer_value(self): + doc = Document("ports:\n - 8080\n - 9090\n") + result = doc.ensure_in_list("ports", value=8080) + assert result.source == doc.source + + def test_path_missing_creates_list(self): + doc = Document("name: foo\n") + result = doc.ensure_in_list("items", value="first") + assert result["items"] == ["first"] + + def test_path_not_a_list_raises(self): + doc = Document("name: foo\n") + with pytest.raises(NodeTypeError): + doc.ensure_in_list("name", value="bar") + + def test_idempotent(self): + doc = Document("items:\n - a\n") + result1 = doc.ensure_in_list("items", value="b") + result2 = result1.ensure_in_list("items", value="b") + assert result1.source == result2.source From 98a3bdb232cf371c8989ecf3b8ca112d92544b8e Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 22 May 2026 08:28:52 +1200 Subject: [PATCH 03/11] test: add where-based matching tests for ensure_in_list --- tests/test_document.py | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_document.py b/tests/test_document.py index 5e88bef..7a7f59b 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -778,3 +778,47 @@ def test_idempotent(self): result1 = doc.ensure_in_list("items", value="b") result2 = result1.ensure_in_list("items", value="b") assert result1.source == result2.source + + def test_where_match_found_noop(self): + doc = Document( + "repos:\n - repo: https://a\n rev: v1\n - repo: https://b\n rev: v2\n" + ) + result = doc.ensure_in_list( + "repos", + where={"repo": "https://a"}, + value={"repo": "https://a", "rev": "v1"}, + ) + assert result.source == doc.source + + def test_where_no_match_appends(self): + doc = Document("repos:\n - repo: https://a\n rev: v1\n") + result = doc.ensure_in_list( + "repos", + where={"repo": "https://b"}, + value={"repo": "https://b", "rev": "v2"}, + ) + assert result["repos", 1] == {"repo": "https://b", "rev": "v2"} + + def test_where_empty_raises(self): + doc = Document("items:\n - a\n") + with pytest.raises(ValueError, match="where must be a non-empty dict"): + doc.ensure_in_list("items", where={}, value="x") + + def test_where_items_not_dicts_appends(self): + doc = Document("items:\n - a\n - b\n") + result = doc.ensure_in_list( + "items", + where={"name": "foo"}, + value={"name": "foo"}, + ) + assert result["items"] == ["a", "b", {"name": "foo"}] + + def test_where_multiple_keys_all_must_match(self): + doc = Document("items:\n - name: foo\n ver: 1\n - name: bar\n ver: 2\n") + result = doc.ensure_in_list( + "items", + where={"name": "foo", "ver": 2}, + value={"name": "foo", "ver": 2}, + ) + # Neither item matches both (foo has ver=1, bar has ver=2) + assert len(result["items"]) == 3 From 0ab57ccd7f936d2a891c64cd18fcc31d801c3362 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 22 May 2026 08:29:49 +1200 Subject: [PATCH 04/11] test: add flow sequence tests for ensure_in_list --- tests/test_document.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_document.py b/tests/test_document.py index 7a7f59b..68c5fb4 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -822,3 +822,13 @@ def test_where_multiple_keys_all_must_match(self): ) # Neither item matches both (foo has ver=1, bar has ver=2) assert len(result["items"]) == 3 + + def test_flow_sequence_appends(self): + doc = Document("items: [a, b]\n") + result = doc.ensure_in_list("items", value="c") + assert result["items"] == ["a", "b", "c"] + + def test_flow_sequence_noop(self): + doc = Document("items: [a, b]\n") + result = doc.ensure_in_list("items", value="a") + assert result.source == doc.source From ca243cefd995dfe4b7d59ffddcc03ffc0e82d88b Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 22 May 2026 08:31:07 +1200 Subject: [PATCH 05/11] feat: add ensure_in_list to Editor --- src/yamltrip/editor.py | 6 ++++++ tests/test_editor.py | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/yamltrip/editor.py b/src/yamltrip/editor.py index d66e244..a723726 100644 --- a/src/yamltrip/editor.py +++ b/src/yamltrip/editor.py @@ -141,6 +141,12 @@ 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 ensure_in_list( + self, *keys: KeyPart, value: Any, where: dict[str, Any] | None = None + ) -> None: + """Ensure a value is present in the sequence at path.""" + self._document = self.document.ensure_in_list(*keys, value=value, where=where) + 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) diff --git a/tests/test_editor.py b/tests/test_editor.py index 37c9ffe..ab1f901 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -195,6 +195,32 @@ def test_non_list_raises_node_type_error(self, tmp_path): ed.find_index("name", where={"k": "v"}) +class TestEditorEnsureInList: + def test_scalar_missing_appends(self, yaml_file): + with Editor(yaml_file) as editor: + editor.ensure_in_list("items", value="c") + content = yaml_file.read_text(encoding="utf-8") + assert "- c" in content + + def test_scalar_present_noop(self, yaml_file): + with Editor(yaml_file) as editor: + editor.ensure_in_list("items", value="a") + content = yaml_file.read_text(encoding="utf-8") + assert content == "name: foo\nage: 30\nitems:\n - a\n - b\n" + + def test_where_matching(self, tmp_path): + p = tmp_path / "test.yml" + p.write_text("repos:\n - name: foo\n ver: 1\n", encoding="utf-8") + with Editor(p) as editor: + editor.ensure_in_list( + "repos", + where={"name": "foo"}, + value={"name": "foo", "ver": 1}, + ) + content = p.read_text(encoding="utf-8") + assert content == "repos:\n - name: foo\n ver: 1\n" + + class TestEditorGet: def test_get_existing_key(self, yaml_file): with Editor(yaml_file) as editor: From 4a75c196f7795a1628f21379565394c3675e0f68 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 22 May 2026 08:32:01 +1200 Subject: [PATCH 06/11] test: add nested path creation tests for ensure_in_list --- tests/test_document.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_document.py b/tests/test_document.py index 68c5fb4..797f199 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -832,3 +832,14 @@ def test_flow_sequence_noop(self): doc = Document("items: [a, b]\n") result = doc.ensure_in_list("items", value="a") assert result.source == doc.source + + def test_nested_path_missing_creates(self): + doc = Document("config:\n name: foo\n") + result = doc.ensure_in_list("config", "hooks", value="pre-commit") + assert result["config", "hooks"] == ["pre-commit"] + + def test_deeply_nested_path(self): + doc = Document("a:\n b: 1\n") + result = doc.ensure_in_list("a", "c", value="x") + assert result["a", "c"] == ["x"] + assert result["a", "b"] == 1 From d14a04f25d1e715980994d038dd32159600ad8af Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 22 May 2026 08:32:35 +1200 Subject: [PATCH 07/11] docs: add ensure_in_list examples to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 645d99b..d7d1c1a 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ doc.append("items", value="c") doc.insert("items", index=1, value="between") # positional insert doc.extend_list("items", values=["d", "e"]) doc.remove_from_list("items", values=["a"]) +doc.ensure_in_list("items", value="c") # no-op if already present +doc.ensure_in_list("repos", where={"name": "x"}, value={"name": "x", "url": "..."}) doc.sync("items", value=["a", "new", "b"]) # minimal diff-and-patch doc.find_index("repos", where={"id": "x"}) # find in list-of-dicts; returns int | None From 59ab92a073116534f3cbc1c4ac283787fcf8b787 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 22 May 2026 09:02:24 +1200 Subject: [PATCH 08/11] test: add mapping-target NodeTypeError test for ensure_in_list --- tests/test_document.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_document.py b/tests/test_document.py index 797f199..16149d1 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -773,6 +773,11 @@ def test_path_not_a_list_raises(self): with pytest.raises(NodeTypeError): doc.ensure_in_list("name", value="bar") + def test_path_is_mapping_raises(self): + doc = Document("config:\n host: localhost\n port: 8080\n") + with pytest.raises(NodeTypeError): + doc.ensure_in_list("config", value="x") + def test_idempotent(self): doc = Document("items:\n - a\n") result1 = doc.ensure_in_list("items", value="b") From 605df0a1fd9e94e712bc7e81c1760a796527005c Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 22 May 2026 09:02:40 +1200 Subject: [PATCH 09/11] docs: use concrete value in ensure_in_list where example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d7d1c1a..28fc626 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ doc.insert("items", index=1, value="between") # positional insert doc.extend_list("items", values=["d", "e"]) doc.remove_from_list("items", values=["a"]) doc.ensure_in_list("items", value="c") # no-op if already present -doc.ensure_in_list("repos", where={"name": "x"}, value={"name": "x", "url": "..."}) +doc.ensure_in_list("repos", where={"name": "x"}, value={"name": "x", "version": "1.0"}) doc.sync("items", value=["a", "new", "b"]) # minimal diff-and-patch doc.find_index("repos", where={"id": "x"}) # find in list-of-dicts; returns int | None From cc0e9b0c604edc05341faa58254ef1762faa1fa8 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 22 May 2026 09:35:47 +1200 Subject: [PATCH 10/11] test: assert full content in editor ensure_in_list append test --- tests/test_editor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_editor.py b/tests/test_editor.py index ab1f901..4ad8f62 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -200,7 +200,7 @@ def test_scalar_missing_appends(self, yaml_file): with Editor(yaml_file) as editor: editor.ensure_in_list("items", value="c") content = yaml_file.read_text(encoding="utf-8") - assert "- c" in content + assert content == "name: foo\nage: 30\nitems:\n - a\n - b\n - c\n" def test_scalar_present_noop(self, yaml_file): with Editor(yaml_file) as editor: From b8a859f72d39aaf1c6bd4fd67e8a8b71c18612f6 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 22 May 2026 10:11:30 +1200 Subject: [PATCH 11/11] docs: note PatchError on root-empty edge case in ensure_in_list --- src/yamltrip/document.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/yamltrip/document.py b/src/yamltrip/document.py index 329bbc1..92f0dec 100644 --- a/src/yamltrip/document.py +++ b/src/yamltrip/document.py @@ -457,6 +457,8 @@ def ensure_in_list( Raises: NodeTypeError: If the value at path is not a list. ValueError: If where is an empty dict. + PatchError: If keys is empty and the document is empty (root + sequence creation is not supported). """ if where is not None and not where: msg = "where must be a non-empty dict"