Skip to content

Commit 8961ef9

Browse files
refactor: centralise patch-error classification and apply stepdown rule
Add `_PatchErrorKind` enum and `_classify_patch_error()` to confine all yamlpatch error-message substrings in one place. Refactor `append`, `insert`, `extend_list`, and `sync` to branch on the enum rather than matching raw strings directly. Add `TestPatchErrorStringPins` to `test_edge_cases.py` to pin each raw yamlpatch substring against a live Rust error so any upstream change to the error messages is caught immediately. Apply stepdown rule throughout `document.py` and `editor.py`: - Move `_apply_patches`/`_from_core` to after `find_index` - Move `_create_at`/`_is_empty_document` to after `upsert` - Swap `prune_remove`/`remove` so the delegating wrapper comes first - Move module-level private helpers to after the `Document` class - Move `Editor.document` property to after all delegation methods
1 parent d4ad092 commit 8961ef9

3 files changed

Lines changed: 214 additions & 128 deletions

File tree

src/yamltrip/document.py

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

33
from __future__ import annotations
44

5+
import enum
56
from pathlib import Path
67
from 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-
10957
class 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

src/yamltrip/editor.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,6 @@ def original(self) -> Document:
7979
raise RuntimeError(msg)
8080
return self._original
8181

82-
@property
83-
def document(self) -> Document:
84-
"""The current in-progress document."""
85-
if self._document is None:
86-
msg = "Editor must be used as a context manager"
87-
raise RuntimeError(msg)
88-
return self._document
89-
9082
@property
9183
def root(self) -> Any:
9284
"""The entire document parsed as a Python object."""
@@ -170,3 +162,11 @@ def query(self, *keys: KeyPart) -> Feature:
170162
def extract(self, feature: Feature) -> str:
171163
"""Extract the raw YAML text for a feature."""
172164
return self.document.extract(feature)
165+
166+
@property
167+
def document(self) -> Document:
168+
"""The current in-progress document."""
169+
if self._document is None:
170+
msg = "Editor must be used as a context manager"
171+
raise RuntimeError(msg)
172+
return self._document

0 commit comments

Comments
 (0)