22
33from __future__ import annotations
44
5+ import enum
56from pathlib import Path
67from typing import TYPE_CHECKING , Any , cast
78
@@ -53,59 +54,6 @@ def _make_route(keys: Sequence[KeyPart]) -> Route:
5354 return Route (list (keys ))
5455
5556
56- def _ensure_str_keys (keys : tuple [KeyPart , ...]) -> tuple [str , ...]:
57- """Validate all keys are strings for creation operations.
58-
59- Raises PatchError if any key is an int (cannot create sequences via upsert).
60- """
61- for k in keys :
62- if isinstance (k , int ):
63- msg = (
64- f"Cannot create intermediate structure with integer key { k } ; "
65- "only string keys can create new mappings"
66- )
67- raise PatchError (msg )
68- return cast ("tuple[str, ...]" , keys )
69-
70-
71- def _flow_seq_replacements (
72- core_doc : CoreDocument ,
73- old_value : Any ,
74- new_value : Any ,
75- path : tuple [KeyPart , ...],
76- ) -> list [Patch ]:
77- """Find flow sequences that need modification and emit targeted replace patches."""
78- patches : list [Patch ] = []
79-
80- if isinstance (old_value , list ) and isinstance (new_value , list ):
81- if old_value != new_value :
82- route = _make_route (path )
83- try :
84- feature = core_doc .query_exact (route )
85- if feature and feature .kind == FeatureKind .FlowSequence :
86- patches .append (Patch (route = route , operation = Op .replace (new_value )))
87- return patches
88- except (KeyError , ValueError ):
89- pass
90- # Recurse into shared list elements to find nested flow sequences
91- for i in range (min (len (old_value ), len (new_value ))):
92- sub_patches = _flow_seq_replacements (
93- core_doc , old_value [i ], new_value [i ], (* path , i )
94- )
95- patches .extend (sub_patches )
96- return patches
97-
98- if isinstance (old_value , dict ) and isinstance (new_value , dict ):
99- for key in new_value :
100- if key in old_value :
101- sub_patches = _flow_seq_replacements (
102- core_doc , old_value [key ], new_value [key ], (* path , key )
103- )
104- patches .extend (sub_patches )
105-
106- return patches
107-
108-
10957class Document :
11058 """An immutable YAML document.
11159
@@ -128,22 +76,6 @@ def __init__(self, source: str) -> None:
12876 raise ParseError (str (e )) from None
12977 self ._source : str = source
13078
131- @classmethod
132- def _from_core (cls , core_doc : CoreDocument ) -> Document :
133- """Construct a Document from an already-parsed CoreDocument."""
134- obj = object .__new__ (cls )
135- obj ._core_doc = core_doc
136- obj ._source = core_doc .source ()
137- return obj
138-
139- def _apply_patches (self , patches : list [Patch ]) -> Document :
140- """Apply patches to this document and return a new Document."""
141- try :
142- core_doc = self ._core_doc .apply_patches (patches )
143- except RuntimeError as e :
144- raise PatchError (str (e )) from None
145- return Document ._from_core (core_doc )
146-
14779 @property
14880 def source (self ) -> str :
14981 """The current YAML source text."""
@@ -267,9 +199,34 @@ def add(self, *keys: KeyPart, key: str, value: Any) -> Document:
267199 patch = Patch (route = route , operation = op )
268200 return self ._apply_patches ([patch ])
269201
270- def _is_empty_document (self ) -> bool :
271- """True if the document has no root data node."""
272- return not self ._core_doc .query_exists (_make_route (()))
202+ def upsert (self , * keys : KeyPart , value : Any ) -> Document :
203+ """Replace if exists, create (with intermediate mappings) if not."""
204+ if not keys :
205+ if self ._is_empty_document ():
206+ msg = (
207+ "Cannot replace root of an empty document; provide at least one key"
208+ )
209+ raise PatchError (msg )
210+ route = _make_route (())
211+ op = Op .replace (value )
212+ patch = Patch (route = route , operation = op )
213+ return self ._apply_patches ([patch ])
214+
215+ full_route = _make_route (keys )
216+ if self ._core_doc .query_exists (full_route ):
217+ return self .replace (* keys , value = value )
218+
219+ # Find deepest existing ancestor
220+ for depth in range (len (keys ) - 1 , 0 , - 1 ):
221+ ancestor_keys = keys [:depth ]
222+ ancestor_route = _make_route (ancestor_keys )
223+ if self ._core_doc .query_exists (ancestor_route ):
224+ return self ._create_at (
225+ ancestor_keys , _ensure_str_keys (keys [depth :]), value
226+ )
227+
228+ # No path exists — add at root
229+ return self ._create_at ((), _ensure_str_keys (keys ), value )
273230
274231 def _create_at (
275232 self ,
@@ -315,34 +272,13 @@ def _create_at(
315272 patch = Patch (route = route , operation = op )
316273 return self ._apply_patches ([patch ])
317274
318- def upsert (self , * keys : KeyPart , value : Any ) -> Document :
319- """Replace if exists, create (with intermediate mappings) if not."""
320- if not keys :
321- if self ._is_empty_document ():
322- msg = (
323- "Cannot replace root of an empty document; provide at least one key"
324- )
325- raise PatchError (msg )
326- route = _make_route (())
327- op = Op .replace (value )
328- patch = Patch (route = route , operation = op )
329- return self ._apply_patches ([patch ])
330-
331- full_route = _make_route (keys )
332- if self ._core_doc .query_exists (full_route ):
333- return self .replace (* keys , value = value )
334-
335- # Find deepest existing ancestor
336- for depth in range (len (keys ) - 1 , 0 , - 1 ):
337- ancestor_keys = keys [:depth ]
338- ancestor_route = _make_route (ancestor_keys )
339- if self ._core_doc .query_exists (ancestor_route ):
340- return self ._create_at (
341- ancestor_keys , _ensure_str_keys (keys [depth :]), value
342- )
275+ def _is_empty_document (self ) -> bool :
276+ """True if the document has no root data node."""
277+ return not self ._core_doc .query_exists (_make_route (()))
343278
344- # No path exists — add at root
345- return self ._create_at ((), _ensure_str_keys (keys ), value )
279+ def prune_remove (self , * keys : KeyPart ) -> Document :
280+ """Remove key and prune empty parents."""
281+ return self .remove (* keys , prune = True )
346282
347283 def remove (self , * keys : KeyPart , prune : bool = False ) -> Document :
348284 """Remove the key/index at path."""
@@ -364,10 +300,6 @@ def remove(self, *keys: KeyPart, prune: bool = False) -> Document:
364300 break
365301 return doc
366302
367- def prune_remove (self , * keys : KeyPart ) -> Document :
368- """Remove key and prune empty parents."""
369- return self .remove (* keys , prune = True )
370-
371303 def append (self , * keys : KeyPart , value : Any ) -> Document :
372304 """Append a single item to the sequence at path."""
373305 route = _make_route (keys )
@@ -376,15 +308,14 @@ def append(self, *keys: KeyPart, value: Any) -> Document:
376308 try :
377309 return self ._apply_patches ([patch ])
378310 except PatchError as e :
379- msg = str (e )
380- # yamlpatch raises "...flow sequence..." for append on FlowSequence nodes
381- if "flow sequence" in msg :
311+ kind = _classify_patch_error (e )
312+ if kind == _PatchErrorKind .FLOW_SEQUENCE :
382313 current = self [keys ]
383314 new_list = [* list (current ), value ]
384315 replace_op = Op .replace (new_list )
385316 return self ._apply_patches ([Patch (route = route , operation = replace_op )])
386- if "only permitted against sequence" in msg :
387- raise NodeTypeError (msg ) from None
317+ if kind == _PatchErrorKind . NOT_A_SEQUENCE :
318+ raise NodeTypeError (str ( e ) ) from None
388319 raise
389320
390321 def insert (self , * keys : KeyPart , index : int , value : Any ) -> Document :
@@ -399,14 +330,13 @@ def insert(self, *keys: KeyPart, index: int, value: Any) -> Document:
399330 try :
400331 return self ._apply_patches ([patch ])
401332 except PatchError as e :
402- msg = str (e )
403- # Rust apply_insert_at raises "expected BlockSequence, got ..." for
404- # both FlowSequence and non-sequence nodes (Scalar, BlockMapping, etc.)
405- if "expected BlockSequence" not in msg :
333+ # Rust apply_insert_at raises BLOCK_SEQUENCE_EXPECTED for both
334+ # FlowSequence and non-sequence nodes (Scalar, BlockMapping, etc.)
335+ if _classify_patch_error (e ) != _PatchErrorKind .BLOCK_SEQUENCE_EXPECTED :
406336 raise
407337 current = self [keys ]
408338 if not isinstance (current , list ):
409- raise NodeTypeError (msg ) from None
339+ raise NodeTypeError (str ( e ) ) from None
410340 new_list : list [Any ] = list (current )
411341 new_list .insert (index , value )
412342 replace_op = Op .replace (new_list )
@@ -421,15 +351,14 @@ def extend_list(self, *keys: KeyPart, values: Sequence[Any]) -> Document:
421351 try :
422352 return self ._apply_patches (patches )
423353 except PatchError as e :
424- msg = str (e )
425- # yamlpatch raises "...flow sequence..." for append on FlowSequence nodes
426- if "flow sequence" in msg :
354+ kind = _classify_patch_error (e )
355+ if kind == _PatchErrorKind .FLOW_SEQUENCE :
427356 current = self [keys ]
428357 new_list = [* list (current ), * values ]
429358 replace_op = Op .replace (new_list )
430359 return self ._apply_patches ([Patch (route = route , operation = replace_op )])
431- if "only permitted against sequence" in msg :
432- raise NodeTypeError (msg ) from None
360+ if kind == _PatchErrorKind . NOT_A_SEQUENCE :
361+ raise NodeTypeError (str ( e ) ) from None
433362 raise
434363
435364 def remove_from_list (self , * keys : KeyPart , values : Sequence [Any ]) -> Document :
@@ -543,7 +472,7 @@ def sync(self, *keys: KeyPart, value: Any) -> Document:
543472 try :
544473 return doc ._apply_patches (patches )
545474 except PatchError as e :
546- if "expected BlockSequence" not in str (e ):
475+ if _classify_patch_error (e ) != _PatchErrorKind . BLOCK_SEQUENCE_EXPECTED :
547476 raise
548477 # Fallback: a flow sequence was missed by pre-detection (e.g. due to
549478 # list reordering). Replace the entire synced value.
@@ -634,3 +563,97 @@ def find_index(self, *keys: KeyPart, where: dict[str, Any]) -> int | None:
634563 if all (k in entry and entry [k ] == v for k , v in where .items ()):
635564 return i
636565 return None
566+
567+ def _apply_patches (self , patches : list [Patch ]) -> Document :
568+ """Apply patches to this document and return a new Document."""
569+ try :
570+ core_doc = self ._core_doc .apply_patches (patches )
571+ except RuntimeError as e :
572+ raise PatchError (str (e )) from None
573+ return Document ._from_core (core_doc )
574+
575+ @classmethod
576+ def _from_core (cls , core_doc : CoreDocument ) -> Document :
577+ """Construct a Document from an already-parsed CoreDocument."""
578+ obj = object .__new__ (cls )
579+ obj ._core_doc = core_doc
580+ obj ._source = core_doc .source ()
581+ return obj
582+
583+
584+ def _ensure_str_keys (keys : tuple [KeyPart , ...]) -> tuple [str , ...]:
585+ """Validate all keys are strings for creation operations.
586+
587+ Raises PatchError if any key is an int (cannot create sequences via upsert).
588+ """
589+ for k in keys :
590+ if isinstance (k , int ):
591+ msg = (
592+ f"Cannot create intermediate structure with integer key { k } ; "
593+ "only string keys can create new mappings"
594+ )
595+ raise PatchError (msg )
596+ return cast ("tuple[str, ...]" , keys )
597+
598+
599+ def _flow_seq_replacements (
600+ core_doc : CoreDocument ,
601+ old_value : Any ,
602+ new_value : Any ,
603+ path : tuple [KeyPart , ...],
604+ ) -> list [Patch ]:
605+ """Find flow sequences that need modification and emit targeted replace patches."""
606+ patches : list [Patch ] = []
607+
608+ if isinstance (old_value , list ) and isinstance (new_value , list ):
609+ if old_value != new_value :
610+ route = _make_route (path )
611+ try :
612+ feature = core_doc .query_exact (route )
613+ if feature and feature .kind == FeatureKind .FlowSequence :
614+ patches .append (Patch (route = route , operation = Op .replace (new_value )))
615+ return patches
616+ except (KeyError , ValueError ):
617+ pass
618+ # Recurse into shared list elements to find nested flow sequences
619+ for i in range (min (len (old_value ), len (new_value ))):
620+ sub_patches = _flow_seq_replacements (
621+ core_doc , old_value [i ], new_value [i ], (* path , i )
622+ )
623+ patches .extend (sub_patches )
624+ return patches
625+
626+ if isinstance (old_value , dict ) and isinstance (new_value , dict ):
627+ for key in new_value :
628+ if key in old_value :
629+ sub_patches = _flow_seq_replacements (
630+ core_doc , old_value [key ], new_value [key ], (* path , key )
631+ )
632+ patches .extend (sub_patches )
633+
634+ return patches
635+
636+
637+ class _PatchErrorKind (enum .Enum ):
638+ """Classifies a PatchError by its originating yamlpatch error message."""
639+
640+ FLOW_SEQUENCE = "flow sequence"
641+ NOT_A_SEQUENCE = "only permitted against sequence"
642+ BLOCK_SEQUENCE_EXPECTED = "expected BlockSequence"
643+ UNKNOWN = ""
644+
645+
646+ def _classify_patch_error (err : PatchError ) -> _PatchErrorKind :
647+ """Return the kind of a PatchError based on its message string.
648+
649+ All yamlpatch error-message substrings are confined here so that
650+ callers can branch on the enum rather than matching raw strings.
651+ """
652+ msg = str (err )
653+ if _PatchErrorKind .FLOW_SEQUENCE .value in msg :
654+ return _PatchErrorKind .FLOW_SEQUENCE
655+ if _PatchErrorKind .NOT_A_SEQUENCE .value in msg :
656+ return _PatchErrorKind .NOT_A_SEQUENCE
657+ if _PatchErrorKind .BLOCK_SEQUENCE_EXPECTED .value in msg :
658+ return _PatchErrorKind .BLOCK_SEQUENCE_EXPECTED
659+ return _PatchErrorKind .UNKNOWN
0 commit comments