Skip to content

Commit cc7e1ee

Browse files
feat: raise RoutingError for routing failures; simplify merge()
1 parent df87bdd commit cc7e1ee

4 files changed

Lines changed: 99 additions & 44 deletions

File tree

src/yamltrip/document.py

Lines changed: 47 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
ParseError,
2727
PatchError,
2828
QueryError,
29+
RoutingError,
2930
)
3031

3132
if TYPE_CHECKING:
@@ -197,7 +198,13 @@ def add(self, *keys: KeyPart, key: str, value: Any) -> Document:
197198
route = _make_route(keys)
198199
op = Op.add(key, value)
199200
patch = Patch(route=route, operation=op)
200-
return self._apply_patches([patch])
201+
try:
202+
return self._apply_patches([patch])
203+
except PatchError as e:
204+
if _is_routing_error(_classify_patch_error(e)):
205+
msg = f"Route passes through a non-mapping node at {format_path(keys)}"
206+
raise RoutingError(msg) from None
207+
raise
201208

202209
def upsert(self, *keys: KeyPart, value: Any) -> Document:
203210
"""Replace if exists, create (with intermediate mappings) if not."""
@@ -253,24 +260,30 @@ def _create_at(
253260
for k in reversed(child_keys[1:]):
254261
nested_value = {k: nested_value}
255262
route = _make_route(parent_keys)
256-
if isinstance(nested_value, dict) and any(
257-
isinstance(v, (dict, list, tuple)) for v in nested_value.values()
258-
):
259-
# Op.merge_into is scoped to flat mappings (uniform indent); for
260-
# nested values, add a placeholder then replace via complex-replace
261-
# which preserves relative indentation.
262-
add_op = Op.add(first_key, None)
263-
add_patch = Patch(route=route, operation=add_op)
264-
replace_route = _make_route((*parent_keys, first_key))
265-
replace_op = Op.replace(nested_value)
266-
replace_patch = Patch(route=replace_route, operation=replace_op)
267-
return self._apply_patches([add_patch, replace_patch])
268-
elif isinstance(nested_value, dict):
269-
op = Op.merge_into(first_key, nested_value)
270-
else:
271-
op = Op.add(first_key, nested_value)
272-
patch = Patch(route=route, operation=op)
273-
return self._apply_patches([patch])
263+
try:
264+
if isinstance(nested_value, dict) and any(
265+
isinstance(v, (dict, list, tuple)) for v in nested_value.values()
266+
):
267+
# Op.merge_into is scoped to flat mappings (uniform indent); for
268+
# nested values, add a placeholder then replace via complex-replace
269+
# which preserves relative indentation.
270+
add_op = Op.add(first_key, None)
271+
add_patch = Patch(route=route, operation=add_op)
272+
replace_route = _make_route((*parent_keys, first_key))
273+
replace_op = Op.replace(nested_value)
274+
replace_patch = Patch(route=replace_route, operation=replace_op)
275+
return self._apply_patches([add_patch, replace_patch])
276+
elif isinstance(nested_value, dict):
277+
op = Op.merge_into(first_key, nested_value)
278+
else:
279+
op = Op.add(first_key, nested_value)
280+
patch = Patch(route=route, operation=op)
281+
return self._apply_patches([patch])
282+
except PatchError as e:
283+
if _is_routing_error(_classify_patch_error(e)):
284+
msg = f"Route passes through a non-mapping node at {format_path(parent_keys)}"
285+
raise RoutingError(msg) from None
286+
raise
274287

275288
def _is_empty_document(self) -> bool:
276289
"""True if the document has no root data node."""
@@ -500,20 +513,7 @@ def merge(self, *keys: KeyPart, value: Any) -> Document:
500513
if normalized:
501514
route = _make_route(normalized)
502515
if not self._core_doc.query_exists(route):
503-
try:
504-
return self.upsert(*normalized, value=value)
505-
except PatchError as e:
506-
if _classify_patch_error(e) == _PatchErrorKind.UNEXPECTED_NODE:
507-
# Find deepest existing ancestor to report
508-
failing = normalized
509-
for i in range(len(normalized), 0, -1):
510-
sub = normalized[:i]
511-
if self._core_doc.query_exists(_make_route(sub)):
512-
failing = sub
513-
break
514-
msg = f"Value at {format_path(failing)} is not a mapping"
515-
raise NodeTypeError(msg) from None
516-
raise
516+
return self.upsert(*normalized, value=value)
517517

518518
# Get current value and diff
519519
try:
@@ -640,6 +640,8 @@ class _PatchErrorKind(enum.Enum):
640640
FLOW_SEQUENCE = "flow sequence"
641641
NOT_A_SEQUENCE = "only permitted against sequence"
642642
BLOCK_SEQUENCE_EXPECTED = "expected BlockSequence"
643+
NON_MAPPING_ROUTE = "non-mapping route"
644+
EXPECTED_MAPPING = "expected mapping containing key"
643645
UNEXPECTED_NODE = "unexpected node"
644646
UNKNOWN = ""
645647

@@ -651,12 +653,16 @@ def _classify_patch_error(err: PatchError) -> _PatchErrorKind:
651653
callers can branch on the enum rather than matching raw strings.
652654
"""
653655
msg = str(err)
654-
if _PatchErrorKind.FLOW_SEQUENCE.value in msg:
655-
return _PatchErrorKind.FLOW_SEQUENCE
656-
if _PatchErrorKind.NOT_A_SEQUENCE.value in msg:
657-
return _PatchErrorKind.NOT_A_SEQUENCE
658-
if _PatchErrorKind.BLOCK_SEQUENCE_EXPECTED.value in msg:
659-
return _PatchErrorKind.BLOCK_SEQUENCE_EXPECTED
660-
if _PatchErrorKind.UNEXPECTED_NODE.value in msg:
661-
return _PatchErrorKind.UNEXPECTED_NODE
656+
for kind in _PatchErrorKind:
657+
if kind.value and kind.value in msg:
658+
return kind
662659
return _PatchErrorKind.UNKNOWN
660+
661+
662+
def _is_routing_error(kind: _PatchErrorKind) -> bool:
663+
"""Return True if kind represents a routing failure through a non-mapping node."""
664+
return kind in (
665+
_PatchErrorKind.NON_MAPPING_ROUTE,
666+
_PatchErrorKind.EXPECTED_MAPPING,
667+
_PatchErrorKind.UNEXPECTED_NODE,
668+
)

tests/test_edge_cases.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,32 @@ def test_classify_unexpected_node(self):
178178
msg = self._raw_error(doc, "name", op=Op.merge_into("nested", {"foo": "bar"}))
179179
assert _classify_patch_error(PatchError(msg)) == _PatchErrorKind.UNEXPECTED_NODE
180180

181+
def test_non_mapping_route_substring(self):
182+
"""Op.add on a scalar node raises an error containing 'non-mapping route'."""
183+
doc = Document("name: scalar\n")
184+
msg = self._raw_error(doc, "name", op=Op.add("child", "val"))
185+
assert _PatchErrorKind.NON_MAPPING_ROUTE.value in msg
186+
187+
def test_classify_non_mapping_route(self):
188+
doc = Document("name: scalar\n")
189+
msg = self._raw_error(doc, "name", op=Op.add("child", "val"))
190+
assert (
191+
_classify_patch_error(PatchError(msg)) == _PatchErrorKind.NON_MAPPING_ROUTE
192+
)
193+
194+
def test_expected_mapping_substring(self):
195+
"""Op.merge_into on a list node raises an error containing 'expected mapping containing key'."""
196+
doc = Document("items:\n - a\n")
197+
msg = self._raw_error(doc, "items", op=Op.merge_into("k", {"a": 1}))
198+
assert _PatchErrorKind.EXPECTED_MAPPING.value in msg
199+
200+
def test_classify_expected_mapping(self):
201+
doc = Document("items:\n - a\n")
202+
msg = self._raw_error(doc, "items", op=Op.merge_into("k", {"a": 1}))
203+
assert (
204+
_classify_patch_error(PatchError(msg)) == _PatchErrorKind.EXPECTED_MAPPING
205+
)
206+
181207
def test_classify_unknown(self):
182208
assert (
183209
_classify_patch_error(PatchError("some unrelated error"))

tests/test_errors.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22

3+
from yamltrip import Document
34
from yamltrip.errors import (
45
KeyExistsError,
56
KeyMissingError,
@@ -59,3 +60,25 @@ def test_raise_and_catch_as_patch_error(self):
5960
def test_message(self):
6061
err = RoutingError("Route passes through a non-mapping node at a")
6162
assert "a" in str(err)
63+
64+
65+
class TestRoutingErrorBehaviour:
66+
def test_upsert_through_scalar_raises_routing_error(self):
67+
doc = Document("a: scalar\n")
68+
with pytest.raises(RoutingError, match="non-mapping node at a"):
69+
doc.upsert("a", "b", value="foo")
70+
71+
def test_upsert_through_list_raises_routing_error(self):
72+
doc = Document("items:\n - x\n")
73+
with pytest.raises(RoutingError, match="non-mapping node at items"):
74+
doc.upsert("items", "b", value="foo")
75+
76+
def test_add_through_scalar_raises_routing_error(self):
77+
doc = Document("name: scalar\n")
78+
with pytest.raises(RoutingError, match="non-mapping node at name"):
79+
doc.add("name", key="child", value="foo")
80+
81+
def test_routing_error_is_catchable_as_patch_error(self):
82+
doc = Document("a: scalar\n")
83+
with pytest.raises(PatchError):
84+
doc.upsert("a", "b", value="foo")

tests/test_merge.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from yamltrip import edit
44
from yamltrip.document import Document
5-
from yamltrip.errors import NodeTypeError
5+
from yamltrip.errors import RoutingError
66

77

88
class TestMergeMapping:
@@ -108,7 +108,7 @@ def test_editor_merge(self, tmp_path):
108108

109109
class TestMergeErrors:
110110
def test_merge_through_scalar_raises(self):
111-
"""Merging through a scalar path raises NodeTypeError."""
111+
"""Merging through a scalar path raises RoutingError."""
112112
doc = Document("a:\n b: 1\n")
113-
with pytest.raises(NodeTypeError, match="Value at a > b"):
113+
with pytest.raises(RoutingError, match="non-mapping node at a > b"):
114114
doc.merge("a", "b", "c", value={"x": 1})

0 commit comments

Comments
 (0)