Skip to content

Commit e8498bb

Browse files
Feature/ensure in list (#38)
* docs: add design spec for ensure_in_list * feat: add ensure_in_list with scalar matching to Document * test: add where-based matching tests for ensure_in_list * test: add flow sequence tests for ensure_in_list * feat: add ensure_in_list to Editor * test: add nested path creation tests for ensure_in_list * docs: add ensure_in_list examples to README * test: add mapping-target NodeTypeError test for ensure_in_list * docs: use concrete value in ensure_in_list where example * test: assert full content in editor ensure_in_list append test * docs: note PatchError on root-empty edge case in ensure_in_list
1 parent 6ef14fa commit e8498bb

6 files changed

Lines changed: 360 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ doc.append("items", value="c")
7979
doc.insert("items", index=1, value="between") # positional insert
8080
doc.extend_list("items", values=["d", "e"])
8181
doc.remove_from_list("items", values=["a"])
82+
doc.ensure_in_list("items", value="c") # no-op if already present
83+
doc.ensure_in_list("repos", where={"name": "x"}, value={"name": "x", "version": "1.0"})
8284
doc.sync("items", value=["a", "new", "b"]) # minimal diff-and-patch
8385
doc.find_index("repos", where={"id": "x"}) # find in list-of-dicts; returns int | None
8486

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# Design: `ensure_in_list` — Idempotent List Addition
2+
3+
**Date:** 2026-05-22
4+
**Status:** Approved
5+
6+
## Problem
7+
8+
"Add this value to the list if it's not already there" requires manual
9+
check-then-append boilerplate:
10+
11+
```python
12+
items = doc["items"]
13+
if "new_hook" not in items:
14+
doc = doc.append("items", value="new_hook")
15+
```
16+
17+
This is the most common pattern when managing hook lists, dependency arrays,
18+
and plugin registrations.
19+
20+
## API
21+
22+
### Document (immutable, returns new Document)
23+
24+
```python
25+
def ensure_in_list(
26+
self,
27+
*keys: KeyPart,
28+
value: Any,
29+
where: dict[str, Any] | None = None,
30+
) -> Document:
31+
```
32+
33+
### Editor (mutable, returns None)
34+
35+
```python
36+
def ensure_in_list(
37+
self,
38+
*keys: KeyPart,
39+
value: Any,
40+
where: dict[str, Any] | None = None,
41+
) -> None:
42+
```
43+
44+
## Semantics
45+
46+
### Scalar matching (`where=None`)
47+
48+
Check if `value` is already in the list via Python `==`. No-op if present,
49+
append if not.
50+
51+
```python
52+
doc = doc.ensure_in_list("hooks", value="pre-commit")
53+
# If "pre-commit" already in doc["hooks"], returns self unchanged.
54+
# Otherwise appends it.
55+
```
56+
57+
### Dict matching (`where={...}`)
58+
59+
Check if any item in the list has all key/value pairs in `where` (AND
60+
semantics, equality via Python `==`). No-op if a match exists, append `value`
61+
if not.
62+
63+
The matched item is NOT updated — `ensure_in_list` guarantees presence, not
64+
correctness. Users who need update-in-place should use `find_index` + `replace`
65+
or `sync`.
66+
67+
```python
68+
doc = doc.ensure_in_list(
69+
"repos",
70+
where={"repo": "https://github.com/pre-commit/mirrors-prettier"},
71+
value={
72+
"repo": "https://github.com/pre-commit/mirrors-prettier",
73+
"rev": "v3.0.0",
74+
"hooks": [{"id": "prettier"}],
75+
},
76+
)
77+
```
78+
79+
### Path missing — create the list
80+
81+
If the path does not exist, create intermediate mappings (like `upsert`) and
82+
set the value to `[value]`.
83+
84+
```python
85+
doc = doc.ensure_in_list("new_list", value="first_item")
86+
# Result: new_list:\n- first_item
87+
```
88+
89+
### Path exists but is not a list
90+
91+
Raise `NodeTypeError`.
92+
93+
### Flow sequence fallback
94+
95+
Same pattern as `append`: catch the flow-sequence error from yamlpatch and fall
96+
back to reading the current list, checking membership in Python, and replacing
97+
with the new list if needed.
98+
99+
### Idempotence
100+
101+
Calling `ensure_in_list` twice with the same arguments always produces the same
102+
result. This is guaranteed by the no-op-if-present semantics.
103+
104+
## What this does NOT include
105+
106+
- **No `values` plural parameter.** Caller loops for multiple items. This
107+
avoids ambiguity about `where` + `values` interaction.
108+
- **No update-in-place.** If `where` matches an existing item, returns self
109+
unchanged regardless of whether `value` differs from the matched item.
110+
- **No `upsert_in_list`.** Deferred until demand appears.
111+
112+
## Error conditions
113+
114+
| Condition | Behavior |
115+
|-----------|----------|
116+
| Path missing | Create list with `[value]` |
117+
| Path is not a list | Raise `NodeTypeError` |
118+
| `where` is empty dict | Raise `ValueError` (matches `find_index`) |
119+
| `where` provided but list items aren't dicts | No match found → append |
120+
121+
## Implementation approach
122+
123+
Pure Python in `document.py`. No Rust changes needed — this composes existing
124+
primitives (`__contains__`, `__getitem__`, `append`, `upsert`).
125+
126+
Rough logic:
127+
128+
```python
129+
def ensure_in_list(self, *keys, value, where=None):
130+
if where is not None:
131+
if not where:
132+
raise ValueError("where must be a non-empty dict")
133+
134+
# Check if path exists
135+
route = _make_route(keys)
136+
if not self._core_doc.query_exists(route):
137+
# Path missing: create with [value]
138+
return self.upsert(*keys, value=[value])
139+
140+
current = self[keys]
141+
if not isinstance(current, list):
142+
msg = f"Value at {keys} is not a list"
143+
raise NodeTypeError(msg)
144+
145+
# Check membership
146+
if where is None:
147+
if value in current:
148+
return self
149+
else:
150+
for item in current:
151+
if isinstance(item, dict) and all(
152+
k in item and item[k] == v for k, v in where.items()
153+
):
154+
return self
155+
156+
# Not present: append
157+
return self.append(*keys, value=value)
158+
```
159+
160+
## Documentation
161+
162+
Add an example to README.md showing both the scalar and `where`-based usage.
163+
164+
## Testing
165+
166+
- Scalar value already present → no-op (same source)
167+
- Scalar value missing → appended
168+
- Dict matching via `where` already present → no-op
169+
- Dict matching via `where` missing → appended
170+
- Path does not exist → list created
171+
- Path is a scalar → `NodeTypeError`
172+
- Path is a mapping → `NodeTypeError`
173+
- Empty `where` dict → `ValueError`
174+
- Flow sequence handling (inline `[a, b]` style)
175+
- Idempotence: calling twice gives same result
176+
- Editor wrapper delegates correctly

src/yamltrip/document.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,53 @@ def remove_from_list(self, *keys: KeyPart, values: Sequence[Any]) -> Document:
438438
]
439439
return self._apply_patches(patches)
440440

441+
def ensure_in_list(
442+
self, *keys: KeyPart, value: Any, where: dict[str, Any] | None = None
443+
) -> Document:
444+
"""Ensure a value is present in the sequence at path.
445+
446+
If the value is already present, returns self unchanged (no-op).
447+
If the path does not exist, creates the list with [value].
448+
Uses Python == for equality checks.
449+
450+
Args:
451+
*keys: Path to the sequence within the document.
452+
value: The value to ensure is in the list.
453+
where: Optional dict of key/value pairs for matching dicts in
454+
the list (AND semantics). If provided, checks whether any
455+
list item matches all pairs; if so, returns self unchanged.
456+
457+
Raises:
458+
NodeTypeError: If the value at path is not a list.
459+
ValueError: If where is an empty dict.
460+
PatchError: If keys is empty and the document is empty (root
461+
sequence creation is not supported).
462+
"""
463+
if where is not None and not where:
464+
msg = "where must be a non-empty dict"
465+
raise ValueError(msg)
466+
467+
route = _make_route(keys)
468+
if not self._core_doc.query_exists(route):
469+
return self.upsert(*keys, value=[value])
470+
471+
current = self[keys]
472+
if not isinstance(current, list):
473+
msg = f"Value at {keys} is not a list"
474+
raise NodeTypeError(msg)
475+
476+
if where is None:
477+
if value in current:
478+
return self
479+
else:
480+
for item in current:
481+
if isinstance(item, dict) and all(
482+
k in item and item[k] == v for k, v in where.items()
483+
):
484+
return self
485+
486+
return self.append(*keys, value=value)
487+
441488
def sync(self, *keys: KeyPart, value: Any) -> Document:
442489
"""Sync the value at path to match the desired value.
443490

src/yamltrip/editor.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ def remove_from_list(self, *keys: KeyPart, values: Sequence[Any]) -> None:
141141
"""Remove all occurrences of given values from the sequence at path."""
142142
self._document = self.document.remove_from_list(*keys, values=values)
143143

144+
def ensure_in_list(
145+
self, *keys: KeyPart, value: Any, where: dict[str, Any] | None = None
146+
) -> None:
147+
"""Ensure a value is present in the sequence at path."""
148+
self._document = self.document.ensure_in_list(*keys, value=value, where=where)
149+
144150
def sync(self, *keys: KeyPart, value: Any) -> None:
145151
"""Sync the value at path to match the desired value."""
146152
self._document = self.document.sync(*keys, value=value)

tests/test_document.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,3 +745,106 @@ def test_root_upsert_on_empty_raises_patch_error(self):
745745
PatchError, match="Cannot replace root of an empty document"
746746
):
747747
doc.upsert(value=42)
748+
749+
750+
class TestEnsureInList:
751+
def test_scalar_already_present_noop(self):
752+
doc = Document("items:\n - a\n - b\n")
753+
result = doc.ensure_in_list("items", value="a")
754+
assert result.source == doc.source
755+
756+
def test_scalar_missing_appends(self):
757+
doc = Document("items:\n - a\n - b\n")
758+
result = doc.ensure_in_list("items", value="c")
759+
assert result["items"] == ["a", "b", "c"]
760+
761+
def test_integer_value(self):
762+
doc = Document("ports:\n - 8080\n - 9090\n")
763+
result = doc.ensure_in_list("ports", value=8080)
764+
assert result.source == doc.source
765+
766+
def test_path_missing_creates_list(self):
767+
doc = Document("name: foo\n")
768+
result = doc.ensure_in_list("items", value="first")
769+
assert result["items"] == ["first"]
770+
771+
def test_path_not_a_list_raises(self):
772+
doc = Document("name: foo\n")
773+
with pytest.raises(NodeTypeError):
774+
doc.ensure_in_list("name", value="bar")
775+
776+
def test_path_is_mapping_raises(self):
777+
doc = Document("config:\n host: localhost\n port: 8080\n")
778+
with pytest.raises(NodeTypeError):
779+
doc.ensure_in_list("config", value="x")
780+
781+
def test_idempotent(self):
782+
doc = Document("items:\n - a\n")
783+
result1 = doc.ensure_in_list("items", value="b")
784+
result2 = result1.ensure_in_list("items", value="b")
785+
assert result1.source == result2.source
786+
787+
def test_where_match_found_noop(self):
788+
doc = Document(
789+
"repos:\n - repo: https://a\n rev: v1\n - repo: https://b\n rev: v2\n"
790+
)
791+
result = doc.ensure_in_list(
792+
"repos",
793+
where={"repo": "https://a"},
794+
value={"repo": "https://a", "rev": "v1"},
795+
)
796+
assert result.source == doc.source
797+
798+
def test_where_no_match_appends(self):
799+
doc = Document("repos:\n - repo: https://a\n rev: v1\n")
800+
result = doc.ensure_in_list(
801+
"repos",
802+
where={"repo": "https://b"},
803+
value={"repo": "https://b", "rev": "v2"},
804+
)
805+
assert result["repos", 1] == {"repo": "https://b", "rev": "v2"}
806+
807+
def test_where_empty_raises(self):
808+
doc = Document("items:\n - a\n")
809+
with pytest.raises(ValueError, match="where must be a non-empty dict"):
810+
doc.ensure_in_list("items", where={}, value="x")
811+
812+
def test_where_items_not_dicts_appends(self):
813+
doc = Document("items:\n - a\n - b\n")
814+
result = doc.ensure_in_list(
815+
"items",
816+
where={"name": "foo"},
817+
value={"name": "foo"},
818+
)
819+
assert result["items"] == ["a", "b", {"name": "foo"}]
820+
821+
def test_where_multiple_keys_all_must_match(self):
822+
doc = Document("items:\n - name: foo\n ver: 1\n - name: bar\n ver: 2\n")
823+
result = doc.ensure_in_list(
824+
"items",
825+
where={"name": "foo", "ver": 2},
826+
value={"name": "foo", "ver": 2},
827+
)
828+
# Neither item matches both (foo has ver=1, bar has ver=2)
829+
assert len(result["items"]) == 3
830+
831+
def test_flow_sequence_appends(self):
832+
doc = Document("items: [a, b]\n")
833+
result = doc.ensure_in_list("items", value="c")
834+
assert result["items"] == ["a", "b", "c"]
835+
836+
def test_flow_sequence_noop(self):
837+
doc = Document("items: [a, b]\n")
838+
result = doc.ensure_in_list("items", value="a")
839+
assert result.source == doc.source
840+
841+
def test_nested_path_missing_creates(self):
842+
doc = Document("config:\n name: foo\n")
843+
result = doc.ensure_in_list("config", "hooks", value="pre-commit")
844+
assert result["config", "hooks"] == ["pre-commit"]
845+
846+
def test_deeply_nested_path(self):
847+
doc = Document("a:\n b: 1\n")
848+
result = doc.ensure_in_list("a", "c", value="x")
849+
assert result["a", "c"] == ["x"]
850+
assert result["a", "b"] == 1

tests/test_editor.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,32 @@ def test_non_list_raises_node_type_error(self, tmp_path):
195195
ed.find_index("name", where={"k": "v"})
196196

197197

198+
class TestEditorEnsureInList:
199+
def test_scalar_missing_appends(self, yaml_file):
200+
with Editor(yaml_file) as editor:
201+
editor.ensure_in_list("items", value="c")
202+
content = yaml_file.read_text(encoding="utf-8")
203+
assert content == "name: foo\nage: 30\nitems:\n - a\n - b\n - c\n"
204+
205+
def test_scalar_present_noop(self, yaml_file):
206+
with Editor(yaml_file) as editor:
207+
editor.ensure_in_list("items", value="a")
208+
content = yaml_file.read_text(encoding="utf-8")
209+
assert content == "name: foo\nage: 30\nitems:\n - a\n - b\n"
210+
211+
def test_where_matching(self, tmp_path):
212+
p = tmp_path / "test.yml"
213+
p.write_text("repos:\n - name: foo\n ver: 1\n", encoding="utf-8")
214+
with Editor(p) as editor:
215+
editor.ensure_in_list(
216+
"repos",
217+
where={"name": "foo"},
218+
value={"name": "foo", "ver": 1},
219+
)
220+
content = p.read_text(encoding="utf-8")
221+
assert content == "repos:\n - name: foo\n ver: 1\n"
222+
223+
198224
class TestEditorGet:
199225
def test_get_existing_key(self, yaml_file):
200226
with Editor(yaml_file) as editor:

0 commit comments

Comments
 (0)