Skip to content

Commit 45612d0

Browse files
refactor: address review - improve error path, remove dead flow-seq code, rewrite docstring, introduce DiffMode enum
1 parent 324ac99 commit 45612d0

6 files changed

Lines changed: 65 additions & 34 deletions

File tree

.pre-commit-config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ repos:
5555
entry: uv run --frozen cargo clippy --all-targets --no-default-features -- -D warnings
5656
language: system
5757
pass_filenames: false
58+
- repo: https://github.com/jendrikseipp/vulture
59+
rev: v2.16
60+
hooks:
61+
- id: vulture
5862
- repo: https://github.com/codespell-project/codespell
5963
rev: v2.4.2
6064
hooks:

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dev = [
3232
"ruff>=0.15.12",
3333
"tomli>=2.4.1",
3434
"ty>=0.0.35",
35+
"vulture>=2.16",
3536
]
3637
test = [
3738
"coverage[toml]>=7.14.0",
@@ -75,3 +76,10 @@ report.exclude_also = [
7576
"raise NotImplementedError",
7677
]
7778
report.omit = [ "*/pytest-of-*/*" ]
79+
80+
[tool.vulture]
81+
paths = [ "src/yamltrip" ]
82+
ignore_names = [
83+
"dump", # Public API method on Document
84+
"original", # Public API property on Editor
85+
]

src/yamltrip/document.py

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -547,13 +547,16 @@ def sync(self, *keys: KeyPart, value: Any) -> Document:
547547
return self._apply_patches([_core.Patch(route=route, operation=op)])
548548

549549
def merge(self, *keys: KeyPart, value: Any) -> Document:
550-
"""Merge a value into the mapping at path without removing extra keys.
551-
552-
Recursively updates matching keys and adds new keys, but never
553-
removes existing keys not present in *value*. Lists are replaced
554-
entirely (not merged element-wise).
550+
"""Merge or replace the value at path, depending on node type.
551+
552+
If both the existing value and *value* are mappings, matching keys
553+
are updated recursively and new keys are added, but existing keys not
554+
present in *value* are preserved. Lists are replaced entirely (not
555+
merged element-wise), and scalars are replaced. If the path does not
556+
exist, it is created. If the existing node type differs from *value*,
557+
the value at path is replaced.
555558
"""
556-
from yamltrip.sync import _compute_patches # noqa: PLC0415
559+
from yamltrip.sync import DiffMode, _compute_patches # noqa: PLC0415
557560

558561
normalized = _normalize_keys(keys) if keys else ()
559562

@@ -567,7 +570,14 @@ def merge(self, *keys: KeyPart, value: Any) -> Document:
567570
return self.upsert(*normalized, value=value)
568571
except PatchError as e:
569572
if "unexpected node" in str(e):
570-
msg = f"Value at {format_path(normalized)} is not a mapping"
573+
# Find deepest existing ancestor to report
574+
failing = normalized
575+
for i in range(len(normalized), 0, -1):
576+
sub = normalized[:i]
577+
if self._core_doc.query_exists(_make_route(sub)):
578+
failing = sub
579+
break
580+
msg = f"Value at {format_path(failing)} is not a mapping"
571581
raise NodeTypeError(msg) from None
572582
raise
573583

@@ -580,26 +590,10 @@ def merge(self, *keys: KeyPart, value: Any) -> Document:
580590
if old_value == value:
581591
return self
582592

583-
# Pre-convert any flow sequences that will be modified.
584-
doc: Document = self
585-
flow_patches = _flow_seq_replacements(
586-
self._core_doc, old_value, value, normalized
587-
)
588-
if flow_patches:
589-
doc = doc._apply_patches(flow_patches)
590-
old_value = doc._core_doc.parse_value(_make_route(normalized))
591-
592-
patches = _compute_patches(old_value, value, normalized, remove_extra=False)
593+
patches = _compute_patches(old_value, value, normalized, mode=DiffMode.MERGE)
593594
if not patches:
594-
return doc
595-
try:
596-
return doc._apply_patches(patches)
597-
except PatchError as e:
598-
if "expected BlockSequence" not in str(e):
599-
raise
600-
route = _make_route(normalized)
601-
op = _core.Op.replace(value)
602-
return doc._apply_patches([_core.Patch(route=route, operation=op)])
595+
return self
596+
return self._apply_patches(patches)
603597

604598
def find_index(self, *keys: KeyPart, where: dict[str, Any]) -> int | None:
605599
"""Return the index of the first list item matching all key/value pairs.

src/yamltrip/editor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def __exit__(
5757
made between ``__enter__`` and ``__exit__`` but is not atomic and
5858
cannot guard against concurrent writes during the write itself.
5959
"""
60+
del exc_val, exc_tb
6061
if exc_type is None and self._document is not None:
6162
current_source = self._path.read_text(encoding="utf-8")
6263
if current_source != self._original_source:
@@ -152,7 +153,7 @@ def sync(self, *keys: KeyPart, value: Any) -> None:
152153
self._document = self.document.sync(*keys, value=value)
153154

154155
def merge(self, *keys: KeyPart, value: Any) -> None:
155-
"""Merge a value into the mapping at path without removing extra keys."""
156+
"""Merge or replace the value at path, depending on node type."""
156157
self._document = self.document.merge(*keys, value=value)
157158

158159
def find_index(self, *keys: KeyPart, where: dict[str, Any]) -> int | None:

src/yamltrip/sync.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from difflib import SequenceMatcher
6+
from enum import Enum
67
from typing import TYPE_CHECKING, Any
78

89
from yamltrip import _core
@@ -11,12 +12,22 @@
1112
from yamltrip._types import KeyPart
1213

1314

15+
class DiffMode(Enum):
16+
"""Controls how _compute_patches diffs values."""
17+
18+
SYNC = "sync"
19+
"""Exact sync: remove extra keys, diff lists element-wise."""
20+
21+
MERGE = "merge"
22+
"""Additive merge: keep extra keys, replace lists entirely."""
23+
24+
1425
def _compute_patches(
1526
old_value: Any,
1627
new_value: Any,
1728
path: tuple[KeyPart, ...],
1829
*,
19-
remove_extra: bool = True,
30+
mode: DiffMode = DiffMode.SYNC,
2031
) -> list[_core.Patch]:
2132
"""Compute minimal patches to transform old_value into new_value at path."""
2233
if old_value == new_value:
@@ -26,14 +37,13 @@ def _compute_patches(
2637
new_is_dict = isinstance(new_value, dict)
2738

2839
if old_is_dict and new_is_dict:
29-
return _diff_mappings(old_value, new_value, path, remove_extra=remove_extra)
40+
return _diff_mappings(old_value, new_value, path, mode=mode)
3041

3142
old_is_list = isinstance(old_value, list)
3243
new_is_list = isinstance(new_value, list)
3344

3445
if old_is_list and new_is_list:
35-
if not remove_extra:
36-
# merge semantics: lists replace entirely (no element-wise diff)
46+
if mode is DiffMode.MERGE:
3747
route = _core.Route(list(path))
3848
return [_core.Patch(route=route, operation=_core.Op.replace(new_value))]
3949
return _diff_lists(old_value, new_value, path)
@@ -49,7 +59,7 @@ def _diff_mappings(
4959
new: dict[str, Any],
5060
path: tuple[KeyPart, ...],
5161
*,
52-
remove_extra: bool = True,
62+
mode: DiffMode = DiffMode.SYNC,
5363
) -> list[_core.Patch]:
5464
"""Diff two mappings and return patches."""
5565
patches: list[_core.Patch] = []
@@ -58,7 +68,7 @@ def _diff_mappings(
5868
for key in new:
5969
if key in old:
6070
child_patches = _compute_patches(
61-
old[key], new[key], (*path, key), remove_extra=remove_extra
71+
old[key], new[key], (*path, key), mode=mode
6272
)
6373
patches.extend(child_patches)
6474
else:
@@ -68,7 +78,7 @@ def _diff_mappings(
6878
patches.append(_core.Patch(route=route, operation=op))
6979

7080
# Keys in old not in new — remove
71-
if remove_extra:
81+
if mode is DiffMode.SYNC:
7282
for key in old:
7383
if key not in new:
7484
route = _core.Route([*path, key])

uv.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)