Skip to content

fix: iapply raises on invalid patch paths#35

Merged
berendkleinhaneveld merged 2 commits into
masterfrom
fix/iapply-invalid-path
May 26, 2026
Merged

fix: iapply raises on invalid patch paths#35
berendkleinhaneveld merged 2 commits into
masterfrom
fix/iapply-invalid-path

Conversation

@berendkleinhaneveld
Copy link
Copy Markdown
Collaborator

Draft / behavior change — requesting review on direction before merge.

Problem

Pointer.evaluate() in patchdiff/pointer.py swallowed KeyError/TypeError mid-traversal and returned a partially-walked (parent, key) pair. iapply then operated on whatever cursor was last reached, which produced:

  • Silent writes to the wrong parent. A replace on /missing/key against {"present": 1} silently mutated the root dict to {"present": 1, "missing": 99} instead of failing.
  • Confusing downstream errors. Walking into a primitive (/a/b where a is an int) raised AttributeError: 'int' object has no attribute 'remove' from deep inside iapply instead of a clear traversal error.

Reproducer on master:

from patchdiff import apply
from patchdiff.pointer import Pointer

apply({"present": 1}, [{"op": "replace", "path": Pointer(["missing", "key"]), "value": 99}])
# returns {'present': 1, 'missing': 99} — no error

Approach (sketch B from the plan, narrowed)

I went with a hybrid: strict parent walk, lenient leaf lookup.

  • For tokens up to the parent (tokens[:-1]): no try/except — let the underlying container's KeyError / IndexError / TypeError propagate naturally.
  • For the final leaf token: still tolerate lookup failure (this is the case add ops and Pointer(["-"]) rely on), but only when parent is itself a known container (hasattr for keys/append/add). If the parent is a primitive, the natural TypeError from parent[key] propagates.

A pure Plan A (just delete the try/except) initially broke 6 valid-path tests:

  • diff() legitimately produces add patches whose leaf key doesn't exist in the source yet (e.g. adding "c" to {"present": 1}).
  • List append uses Pointer(["-"]), and [1, 2, 3]["-"] raises TypeError.

The narrow form preserves both while fixing the silent-wrong-parent bug.

Callers of evaluate

Only one production caller: patchdiff/apply.py:14 (iapply). Tests reference it but only on valid paths. No external callers depend on the silent behavior.

Decision points — please pick before merge

  1. Where to raise. I picked B (strict in evaluate, lenient at leaf) over A (raise everywhere, simplify iapply) because pure A breaks valid add and list-append patches. An alternative is to keep evaluate fully lenient and have iapply validate after — happy to switch if you prefer that surface.
  2. Which built-in exception. I let the underlying container decide: KeyError (dict), IndexError (list), TypeError (primitive / set / type-mismatched index). The plan flagged mixed paths as ambiguous; I think letting the container speak for itself is least surprising.
  3. Opt-out flag (e.g. ignore_missing=True). Not added. The previous silent behavior masked real bugs; I don't think any caller should rely on it. Flagging in case you disagree.

Tests added

In tests/test_apply.py:

  • test_apply_raises_on_missing_dict_key — asserts KeyError for Pointer(["missing", "key"]) against {"present": 1}.
  • test_apply_raises_on_out_of_range_list_index — asserts IndexError for Pointer([10, "x"]) against [1, 2, 3].
  • test_apply_raises_when_traversing_into_primitive — asserts TypeError for Pointer(["a", "b"]) against {"a": 5}.

In tests/test_pointer.py (same three cases at the evaluate level):

  • test_pointer_evaluate_raises_on_missing_dict_keyKeyError
  • test_pointer_evaluate_raises_on_out_of_range_list_indexIndexError
  • test_pointer_evaluate_raises_when_traversing_into_primitiveTypeError

Verification

  • uv run pytest tests/test_apply.py tests/test_pointer.py -v — all 23 pass (6 new + 17 existing).
  • uv run pytest -x -q — full suite, 274 passed.
  • uv run ruff check / ruff format --check on changed files — clean. (Pre-existing untracked benchmarks/profiler.py has unrelated T201 print warnings; not touched by this PR.)

@berendkleinhaneveld — flagging for direction-check on the three decision points above before this comes out of draft.

🤖 Generated with Claude Code

Pointer.evaluate previously swallowed KeyError/TypeError mid-traversal
and returned a partially-walked (parent, key) pair, causing iapply to
silently write to the wrong parent or surface confusing downstream
AttributeErrors.

Make the parent walk strict (any missing intermediate token raises
KeyError/IndexError/TypeError from the underlying container), while
keeping the leaf lookup lenient so add ops and the list "-" append
token continue to work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread patchdiff/pointer.py Outdated
@berendkleinhaneveld berendkleinhaneveld marked this pull request as ready for review May 26, 2026 11:05
@berendkleinhaneveld berendkleinhaneveld merged commit 0eca6d9 into master May 26, 2026
@berendkleinhaneveld berendkleinhaneveld deleted the fix/iapply-invalid-path branch May 26, 2026 11:18
berendkleinhaneveld added a commit that referenced this pull request May 26, 2026
This version includes the following:

* ci: Update versions of steps and let setup-uv manage the python version (#40)
* perf: trim common prefix/suffix in diff_lists (#36)
* perf: use str.replace in Pointer escape/unescape (#37)
* fix: produce() snapshots mutable values when recording patches (#34)
* perf: linear reverse-op construction in diff_dicts/diff_sets (#38)
* fix: iapply raises on invalid patch paths (#35)
* fix: to_json mutating caller's ops (#33)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants