Skip to content

Commit 0065ec2

Browse files
docs: add RoutingError spec and implementation plan
1 parent cc7e1ee commit 0065ec2

1 file changed

Lines changed: 224 additions & 0 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Design: `RoutingError` Typed Exception
2+
3+
**Date:** 2026-05-26
4+
**Status:** Approved
5+
**Related todo:** `doc/todo/error-routing-typed-exception.md`
6+
7+
---
8+
9+
## Problem
10+
11+
When `upsert`, `add`, or `merge` tries to write through a key path and an
12+
intermediate node is not a mapping (it is a scalar or a list), yamlpatch
13+
raises a `PatchError` whose message contains one of three Rust-layer
14+
substrings:
15+
16+
| Rust substring | Triggering scenario |
17+
|---|---|
18+
| `"non-mapping route"` | `Op.add` with a scalar or list as the parent |
19+
| `"expected mapping containing key"` | `Op.merge_into` with a list as the parent |
20+
| `"unexpected node"` | `Op.merge_into` with a scalar as the parent |
21+
22+
There is currently no typed exception for this condition. Callers who need
23+
to distinguish a routing failure from other `PatchError`s must match raw
24+
strings — fragile and not part of the public API contract.
25+
26+
`merge()` already works around this by catching `_PatchErrorKind.UNEXPECTED_NODE`
27+
and re-raising as `NodeTypeError`. That is an approximation: routing errors
28+
and target-type errors are distinct failure modes and should have distinct
29+
exception types.
30+
31+
---
32+
33+
## Goals
34+
35+
- Introduce `RoutingError` so callers can `except yamltrip.RoutingError`
36+
without any string matching.
37+
- Confine all three routing-related Rust substrings to the existing
38+
`_PatchErrorKind` / `_classify_patch_error` / `_is_routing_error` layer.
39+
- Produce human-readable messages (`"Route passes through a non-mapping node
40+
at a > b"`) rather than leaking Rust internals.
41+
- Fix `merge()` to raise `RoutingError` instead of `NodeTypeError` for
42+
routing failures (breaking change; `NodeTypeError` was an approximation).
43+
44+
---
45+
46+
## Exception Hierarchy
47+
48+
```python
49+
# errors.py — added after KeyMissingError, before NodeTypeError
50+
class RoutingError(PatchError):
51+
"""Raised when a key path passes through a non-mapping node."""
52+
```
53+
54+
`RoutingError` is a direct subclass of `PatchError`. It is **not** a subclass
55+
of `NodeTypeError`: routing errors and target-type errors are semantically
56+
distinct, and `NodeTypeError` carries a `TypeError` mixin that is not
57+
appropriate here.
58+
59+
`RoutingError` is exported in `__init__.py` alongside the other `PatchError`
60+
subclasses.
61+
62+
---
63+
64+
## Detection Layer (`_PatchErrorKind`, `_classify_patch_error`, `_is_routing_error`)
65+
66+
Two new members are added to `_PatchErrorKind` (after `BLOCK_SEQUENCE_EXPECTED`,
67+
before `UNEXPECTED_NODE`):
68+
69+
```python
70+
NON_MAPPING_ROUTE = "non-mapping route"
71+
EXPECTED_MAPPING = "expected mapping containing key"
72+
```
73+
74+
`UNEXPECTED_NODE = "unexpected node"` already exists and is also a routing
75+
error in all contexts where yamlpatch emits it.
76+
77+
`_classify_patch_error` gains two new branches (inserted before the
78+
`UNEXPECTED_NODE` check):
79+
80+
```python
81+
if _PatchErrorKind.NON_MAPPING_ROUTE.value in msg:
82+
return _PatchErrorKind.NON_MAPPING_ROUTE
83+
if _PatchErrorKind.EXPECTED_MAPPING.value in msg:
84+
return _PatchErrorKind.EXPECTED_MAPPING
85+
```
86+
87+
A new module-level helper (placed after `_classify_patch_error`) unifies the
88+
three routing kinds:
89+
90+
```python
91+
def _is_routing_error(kind: _PatchErrorKind) -> bool:
92+
"""Return True if kind represents a routing failure."""
93+
return kind in (
94+
_PatchErrorKind.NON_MAPPING_ROUTE,
95+
_PatchErrorKind.EXPECTED_MAPPING,
96+
_PatchErrorKind.UNEXPECTED_NODE,
97+
)
98+
```
99+
100+
`_is_routing_error` is the single place in the codebase that knows which Rust
101+
error substrings constitute a routing failure.
102+
103+
---
104+
105+
## Raise Sites (Option A: catch in private helpers)
106+
107+
### `_create_at(parent_keys, child_keys, value)`
108+
109+
`_create_at` is the common creation path for both `upsert` and `add` (when the
110+
document is empty). Each `_apply_patches` call that uses `parent_keys` as the
111+
route is wrapped:
112+
113+
```python
114+
try:
115+
return self._apply_patches([patch]) # or [add_patch, replace_patch]
116+
except PatchError as e:
117+
if _is_routing_error(_classify_patch_error(e)):
118+
msg = f"Route passes through a non-mapping node at {format_path(parent_keys)}"
119+
raise RoutingError(msg) from None
120+
raise
121+
```
122+
123+
The bootstrap branch (`not parent_keys and self._is_empty_document()`) creates
124+
a `Document` from scratch and never calls `_apply_patches`, so no catch is
125+
needed there.
126+
127+
### `add(*keys, key, value)`
128+
129+
The non-empty-document path calls `_apply_patches` directly (not via
130+
`_create_at`). That call is wrapped identically, using `keys` as the path:
131+
132+
```python
133+
try:
134+
return self._apply_patches([patch])
135+
except PatchError as e:
136+
if _is_routing_error(_classify_patch_error(e)):
137+
msg = f"Route passes through a non-mapping node at {format_path(keys)}"
138+
raise RoutingError(msg) from None
139+
raise
140+
```
141+
142+
### `merge()`
143+
144+
The existing `_classify_patch_error(e) == _PatchErrorKind.UNEXPECTED_NODE`
145+
block is removed entirely. `upsert` (via `_create_at`) now raises `RoutingError`
146+
directly with a good message; `merge()` just lets it propagate.
147+
148+
The ancestor-finding loop in `merge()` (previously used to construct the
149+
`NodeTypeError` message) is no longer needed.
150+
151+
### Methods that get `RoutingError` for free
152+
153+
`upsert`, `sync`, and any future method that calls `_create_at` raise
154+
`RoutingError` without any further changes.
155+
156+
---
157+
158+
## Public API
159+
160+
### `errors.py`
161+
162+
```python
163+
class RoutingError(PatchError):
164+
"""Raised when a key path passes through a non-mapping node."""
165+
```
166+
167+
### `__init__.py`
168+
169+
`RoutingError` added to the import from `.errors` and to `__all__`, placed
170+
adjacent to the other `PatchError` subclasses.
171+
172+
### `editor.py`
173+
174+
No changes required. `RoutingError` propagates through `Editor`'s delegation
175+
layer automatically.
176+
177+
---
178+
179+
## Tests
180+
181+
### `TestPatchErrorStringPins` additions (in `test_edge_cases.py`)
182+
183+
Two new substring pin tests with their `test_classify_*` counterparts:
184+
185+
- `test_non_mapping_route_substring` — triggers `Op.add` on a scalar parent,
186+
asserts `_PatchErrorKind.NON_MAPPING_ROUTE.value in msg`
187+
- `test_expected_mapping_substring` — triggers `Op.merge_into` on a list parent,
188+
asserts `_PatchErrorKind.EXPECTED_MAPPING.value in msg`
189+
- `test_classify_non_mapping_route` — verifies classifier round-trips to `NON_MAPPING_ROUTE`
190+
- `test_classify_expected_mapping` — verifies classifier round-trips to `EXPECTED_MAPPING`
191+
192+
### New `TestRoutingError` class (in `test_errors.py` or `test_edge_cases.py`)
193+
194+
Behaviour tests that verify the public exception type and message:
195+
196+
- `upsert("a", "b", value=...)` on `a: scalar` raises `RoutingError` with `"at a"` in message
197+
- `add("a", key="b", value=...)` on `a: scalar` raises `RoutingError`
198+
- `merge("a", "b", value=...)` on `a: scalar` raises `RoutingError` (not `NodeTypeError`)
199+
- `isinstance(err, PatchError)` — confirms `RoutingError` IS-A `PatchError`
200+
- `upsert("a", "b", value=...)` on `items: [x, y]` raises `RoutingError`
201+
(covers the `EXPECTED_MAPPING` path)
202+
203+
### Existing tests
204+
205+
`NodeTypeError` tests for `append`, `insert`, `extend_list` on wrong-type
206+
targets are unaffected. Any existing test that asserts `NodeTypeError` from
207+
`merge()` for routing failures must be updated to expect `RoutingError`.
208+
209+
### Public API / stubs
210+
211+
`test_public_api.py` or `test_stubs.py` must be updated to include `RoutingError`
212+
in the expected exports.
213+
214+
---
215+
216+
## Breaking Changes
217+
218+
| Method | Old exception | New exception |
219+
|---|---|---|
220+
| `merge()` routing through non-mapping | `NodeTypeError` | `RoutingError` |
221+
222+
All other methods (`upsert`, `add`, `sync`) previously raised raw `PatchError`
223+
for routing failures; they now raise the more specific `RoutingError`, which is
224+
a `PatchError` subclass — `except PatchError` continues to work.

0 commit comments

Comments
 (0)