2626 ParseError ,
2727 PatchError ,
2828 QueryError ,
29+ RoutingError ,
2930)
3031
3132if 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+ )
0 commit comments