json-source-edit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ """json-source-edit: surgical, byte-preserving JSON editing.
2
+
3
+ "Only change what you intend to change, preserve everything else
4
+ byte-for-byte." See ADR-0013 in the parent codetour-cli project
5
+ (``docs/adr/0013-format-preserving-json-editing.md``) for the design.
6
+ """
7
+ from .editor import JSONEditor, Modification
8
+ from .operations import UnsupportedRemovePath
9
+ from .validator import SemanticValidationError
10
+
11
+ __all__ = [
12
+ "JSONEditor",
13
+ "Modification",
14
+ "UnsupportedRemovePath",
15
+ "SemanticValidationError",
16
+ ]
@@ -0,0 +1,119 @@
1
+ """Core edit-application algorithm.
2
+
3
+ The O(n log n) cursor traversal (ADR-0013's chosen Approach 4: sort edits by
4
+ start position, then build the result in one linear pass, alternating copied
5
+ original-text chunks with spliced-in replacement values), plus the two
6
+ batch-level passes Round 4 added for array-element deletions (§2b):
7
+ coalescing overlapping/adjacent deletion-only edits, and stripping the
8
+ trailing comma a coalesced deletion can orphan.
9
+ """
10
+ from dataclasses import dataclass
11
+ from typing import Dict, List, Optional
12
+
13
+
14
+ @dataclass
15
+ class Edit:
16
+ """A single position-based text replacement.
17
+
18
+ Positions are always in ORIGINAL-document coordinates (ADR-0013 Round 4
19
+ §1's coordinate-frame contract) — they never shift to account for other
20
+ edits in the same batch.
21
+ """
22
+
23
+ start: int
24
+ end: int
25
+ replacement: str
26
+ path: str
27
+
28
+
29
+ def coalesce_deletions(edits: List[Edit]) -> List[Edit]:
30
+ """Merge overlapping/touching deletion-only (``replacement == ""``)
31
+ edits; leave every other edit untouched.
32
+
33
+ Regression: R3-2 (ADR-0013 Round 3) — batch-deleting an array's true
34
+ last element alongside its immediate predecessor makes both claim the
35
+ same comma. Cicerone's Option (ii), affirmed in Round 4 §2b: deletions
36
+ are order-independent, so merging their ranges is semantically safe.
37
+ """
38
+ deletions = sorted((e for e in edits if e.replacement == ""), key=lambda e: e.start)
39
+ others = [e for e in edits if e.replacement != ""]
40
+ if not deletions:
41
+ return sorted(others, key=lambda e: e.start)
42
+
43
+ merged: List[Edit] = [deletions[0]]
44
+ for e in deletions[1:]:
45
+ last = merged[-1]
46
+ if e.start <= last.end:
47
+ merged[-1] = Edit(last.start, max(last.end, e.end), "", last.path)
48
+ else:
49
+ merged.append(e)
50
+ return sorted(merged + others, key=lambda e: e.start)
51
+
52
+
53
+ def strip_orphaned_trailing_commas(
54
+ text: str, edits: List[Edit], array_close_by_path: Dict[str, int]
55
+ ) -> List[Edit]:
56
+ """Splicer's addendum to §2b (ADR-0013 Round 4): coalescing alone can
57
+ still emit invalid JSON. When a (coalesced) deletion range reaches an
58
+ array's closing bracket but the array isn't being emptied entirely, the
59
+ element surviving just before the deleted range keeps a now-orphaned
60
+ trailing comma — it was never last, so it never claimed its own comma.
61
+
62
+ ``array_close_by_path`` maps each array's own JSON-pointer path (the
63
+ parent of any deleted element's path) to the position of that array's
64
+ ``]`` character itself — i.e. ``source_map[array_path].value_end - 1``,
65
+ **not** ``value_end`` directly (``value_end`` is exclusive; passing it
66
+ unadjusted silently no-ops this fix whenever the array isn't the last
67
+ thing in the document — the off-by-one Round 4 caught in itself, see
68
+ ``OPS-2.5.8`` / ``TEST-MATRIX-0013.md``).
69
+ """
70
+ fixed: List[Edit] = []
71
+ for e in edits:
72
+ if e.replacement != "":
73
+ fixed.append(e)
74
+ continue
75
+ start, end = e.start, e.end
76
+ parent = e.path.rsplit("/", 1)[0]
77
+ close_pos: Optional[int] = array_close_by_path.get(parent)
78
+ if (
79
+ close_pos is not None
80
+ and close_pos >= end
81
+ and text[end:close_pos].strip() == ""
82
+ and start > 0
83
+ and text[start - 1] == ","
84
+ ):
85
+ start -= 1
86
+ fixed.append(Edit(start, end, e.replacement, e.path))
87
+ return fixed
88
+
89
+
90
+ def apply_edits(original: str, edits: List[Edit]) -> str:
91
+ """Apply multiple edits to ``original`` in a single linear pass.
92
+
93
+ Args:
94
+ original: the original text.
95
+ edits: edits to apply (sorted internally; the input list is not
96
+ mutated).
97
+
98
+ Returns:
99
+ The modified text.
100
+
101
+ Raises:
102
+ ValueError: if any two edits overlap after sorting.
103
+ """
104
+ sorted_edits = sorted(edits, key=lambda e: e.start)
105
+
106
+ for i in range(len(sorted_edits) - 1):
107
+ if sorted_edits[i].end > sorted_edits[i + 1].start:
108
+ raise ValueError(
109
+ f"Overlapping edits: {sorted_edits[i].path} and {sorted_edits[i + 1].path}"
110
+ )
111
+
112
+ cursor = 0
113
+ result: List[str] = []
114
+ for edit in sorted_edits:
115
+ result.append(original[cursor:edit.start])
116
+ result.append(edit.replacement)
117
+ cursor = edit.end
118
+ result.append(original[cursor:])
119
+ return "".join(result)
@@ -0,0 +1,240 @@
1
+ """JSONEditor: format-preserving JSON editing using surgical text replacement.
2
+
3
+ ADR-0013's core principle: "only change what you intend to change, preserve
4
+ everything else byte-for-byte." Round 4 §1's coordinate-frame contract is
5
+ this class's central discipline: ``self.parsed`` is set once, in
6
+ ``__init__``, and **never mutated afterward** — every path, at every layer
7
+ (operation compilation, audit-trail capture, last-element detection),
8
+ resolves against that same pristine parse, regardless of operation order.
9
+ """
10
+ import difflib
11
+ import json
12
+ import shutil
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional, Union
17
+
18
+ from .algorithm import apply_edits, coalesce_deletions, strip_orphaned_trailing_commas
19
+ from .operations import Operation, Remove, Replace, resolve_path
20
+ from .source_map import SourceMap
21
+ from .validator import SemanticValidationError, validate_semantic_postconditions
22
+ from .source_map import generate as generate_source_map
23
+
24
+
25
+ @dataclass
26
+ class Modification:
27
+ """One recorded modification (the audit trail).
28
+
29
+ ``old_value`` is always captured from the pristine parse at record time
30
+ — Round 4 §1 guarantees this is correct regardless of what else has been
31
+ recorded earlier in the same batch, because nothing before it could have
32
+ mutated ``parsed``.
33
+ """
34
+
35
+ operation: str # "replace" or "remove"
36
+ path: str
37
+ old_value: Any
38
+ new_value: Optional[Any]
39
+ timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
40
+
41
+
42
+ class JSONEditor:
43
+ """Format-preserving JSON editor using surgical text replacement."""
44
+
45
+ def __init__(self, original_text: str, source_map: SourceMap, parsed: Any):
46
+ self.original_text = original_text
47
+ self.source_map = source_map
48
+ self.parsed = parsed # NEVER mutated after this point (Round 4 §1)
49
+ self.operations: List[Operation] = []
50
+ self.modifications: List[Modification] = []
51
+
52
+ @classmethod
53
+ def from_string(cls, text: str) -> "JSONEditor":
54
+ """Load JSON from an in-memory string."""
55
+ parsed = json.loads(text) # raises real json.JSONDecodeError if malformed
56
+ smap = generate_source_map(text)
57
+ return cls(text, smap, parsed)
58
+
59
+ @classmethod
60
+ def from_file(cls, path: Union[str, Path]) -> "JSONEditor":
61
+ """Load a JSON file with position tracking.
62
+
63
+ Reads bytes and decodes UTF-8 explicitly (R3-5) — never
64
+ ``Path.read_text()``, whose universal-newline translation would
65
+ silently convert CRLF to LF before any position is computed.
66
+ """
67
+ text = Path(path).read_bytes().decode("utf-8")
68
+ return cls.from_string(text)
69
+
70
+ def get_value(self, path: str) -> Any:
71
+ """Get the value at a JSON path, from the pristine original parse."""
72
+ return resolve_path(self.parsed, path)
73
+
74
+ def replace(self, path: str, value: Any) -> None:
75
+ """Record a value replacement (does not apply yet)."""
76
+ old_value = self.get_value(path)
77
+ self.operations.append(Replace(path, value))
78
+ self.modifications.append(Modification("replace", path, old_value, value))
79
+
80
+ def remove(self, path: str) -> None:
81
+ """Record a deletion (does not apply yet).
82
+
83
+ Scoped to array-element paths for v1 (ADR-0013 Round 4 §2d) —
84
+ ``operations.UnsupportedRemovePath`` is raised at ``apply()``/
85
+ ``save()`` time (when the operation actually compiles), not here,
86
+ matching the ADR's "record now, compile later" split between
87
+ ``replace()``/``remove()`` and ``apply()``.
88
+ """
89
+ old_value = self.get_value(path)
90
+ self.operations.append(Remove(path))
91
+ self.modifications.append(Modification("remove", path, old_value, None))
92
+
93
+ # Backwards-compatible aliases matching the ADR's original API.
94
+ def set_value(self, path: str, value: Any) -> None:
95
+ """Alias for :meth:`replace`."""
96
+ self.replace(path, value)
97
+
98
+ def delete(self, path: str) -> None:
99
+ """Alias for :meth:`remove`."""
100
+ self.remove(path)
101
+
102
+ def get_modifications(self) -> List[Modification]:
103
+ """Return a copy of all recorded modifications."""
104
+ return self.modifications.copy()
105
+
106
+ def _array_close_positions(self) -> Dict[str, int]:
107
+ """For every array touched by a Remove in this batch, the position
108
+ of that array's own ``]`` character — ``value_end - 1``, never
109
+ ``value_end`` directly (the off-by-one Round 4 caught in itself,
110
+ see ``OPS-2.5.8`` / ``TEST-MATRIX-0013.md``). Keyed by the array's
111
+ own JSON-pointer path so ``strip_orphaned_trailing_commas`` can look
112
+ up the right array for each deletion, even when a batch touches more
113
+ than one array.
114
+ """
115
+ close_positions: Dict[str, int] = {}
116
+ for op in self.operations:
117
+ if isinstance(op, Remove):
118
+ parent_path = op.path.rsplit("/", 1)[0]
119
+ if parent_path not in close_positions and parent_path in self.source_map:
120
+ close_positions[parent_path] = self.source_map[parent_path].value_end - 1
121
+ return close_positions
122
+
123
+ def _compile_edits(self):
124
+ edits = []
125
+ for op in self.operations:
126
+ edits.extend(op.compile(self.source_map, self.parsed, self.original_text))
127
+ edits = coalesce_deletions(edits)
128
+ edits = strip_orphaned_trailing_commas(
129
+ self.original_text, edits, self._array_close_positions()
130
+ )
131
+ return edits
132
+
133
+ def apply(self, validate: bool = False) -> str:
134
+ """Apply all recorded edits and return the modified text (without
135
+ saving).
136
+
137
+ Args:
138
+ validate: if True, parse the result to confirm it's valid JSON.
139
+
140
+ Raises:
141
+ json.JSONDecodeError: if ``validate=True`` and the result doesn't parse.
142
+ validator.SemanticValidationError: if ``validate=True`` and the
143
+ result parses but fails a semantic post-condition (R3-7c) —
144
+ e.g. a position bug replaced the wrong value. This is the
145
+ tripwire ``save()``'s fallback-to-full-rewrite strategy
146
+ depends on; syntactic validity alone isn't enough to trust a
147
+ surgical edit.
148
+ operations.UnsupportedRemovePath: if any recorded ``remove()``
149
+ targets an object-property path (Round 4 §2d).
150
+ ValueError: if two edits overlap after coalescing.
151
+ """
152
+ edits = self._compile_edits()
153
+ modified = apply_edits(self.original_text, edits)
154
+ if validate:
155
+ json.loads(modified)
156
+ errors = validate_semantic_postconditions(self.modifications, self.parsed, modified)
157
+ if errors:
158
+ raise SemanticValidationError(errors)
159
+ return modified
160
+
161
+ def preview_diff(self) -> str:
162
+ """Generate a unified diff preview of the pending changes."""
163
+ modified = self.apply(validate=False)
164
+ return "".join(
165
+ difflib.unified_diff(
166
+ self.original_text.splitlines(keepends=True),
167
+ modified.splitlines(keepends=True),
168
+ fromfile="original",
169
+ tofile="modified",
170
+ )
171
+ )
172
+
173
+ def save(
174
+ self,
175
+ path: Optional[Union[str, Path]] = None,
176
+ backup_suffix: str = ".backup",
177
+ audit_log: Optional[Union[str, Path]] = None,
178
+ validate: bool = True,
179
+ ) -> dict:
180
+ """Apply all edits and save the result.
181
+
182
+ Args:
183
+ path: output path (defaults to the path this editor was loaded
184
+ from, if any).
185
+ backup_suffix: suffix for the backup file, if ``path`` exists.
186
+ audit_log: path to write the modification log to.
187
+ validate: parse the result to confirm it's valid JSON.
188
+
189
+ Returns:
190
+ Dict with ``success``, ``modifications_applied``,
191
+ ``backup_path``, ``audit_log``.
192
+ """
193
+ try:
194
+ modified_text = self.apply(validate=validate)
195
+ except Exception as e: # noqa: BLE001 -- deliberately broad, see "fallback"
196
+ return {
197
+ "success": False,
198
+ "error": f"Failed to apply edits: {e}",
199
+ "fallback": "full_rewrite",
200
+ }
201
+
202
+ backup_path = None
203
+ if path is not None and Path(path).exists():
204
+ backup_path = Path(str(path) + backup_suffix)
205
+ shutil.copy(path, backup_path)
206
+
207
+ if path is not None:
208
+ # R3-5: write bytes explicitly, never Path.write_text() (which
209
+ # would re-translate "\n" back to the platform's line ending).
210
+ Path(path).write_bytes(modified_text.encode("utf-8"))
211
+
212
+ if audit_log is not None:
213
+ log_data = {
214
+ "timestamp": datetime.now().isoformat(),
215
+ "modifications": [
216
+ {
217
+ "operation": m.operation,
218
+ "path": m.path,
219
+ "old_value": m.old_value,
220
+ "new_value": m.new_value,
221
+ "timestamp": m.timestamp,
222
+ }
223
+ for m in self.modifications
224
+ ],
225
+ "stats": {
226
+ "total_operations": len(self.operations),
227
+ "bytes_original": len(self.original_text.encode("utf-8")),
228
+ "bytes_modified": len(modified_text.encode("utf-8")),
229
+ },
230
+ }
231
+ Path(audit_log).write_bytes(
232
+ json.dumps(log_data, indent=2, ensure_ascii=False).encode("utf-8")
233
+ )
234
+
235
+ return {
236
+ "success": True,
237
+ "modifications_applied": len(self.modifications),
238
+ "backup_path": backup_path,
239
+ "audit_log": audit_log,
240
+ }
@@ -0,0 +1,204 @@
1
+ """High-level Operation classes (JSON Patch semantics), compiling to
2
+ low-level Edits.
3
+
4
+ Two-layer design (ADR-0013's Decision): ``Operation`` subclasses are the
5
+ user-facing API; ``compile()`` turns each into one or more ``algorithm.Edit``
6
+ objects. Only ``Replace`` and ``Remove`` are implemented; ``Add``/``Move``/
7
+ ``Copy``/``Test`` are stubbed per the founding brief's scope.
8
+ """
9
+ import json
10
+ import re
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass
13
+ from typing import Any, List
14
+
15
+ from .algorithm import Edit
16
+ from .source_map import SourceMap
17
+
18
+
19
+ def resolve_path(obj: Any, path: str) -> Any:
20
+ """Resolve a JSON Pointer path against ``obj``."""
21
+ if path in ("", "/"):
22
+ return obj
23
+ parts = path.strip("/").split("/")
24
+ current = obj
25
+ for part in parts:
26
+ current = current[int(part)] if isinstance(current, list) else current[part]
27
+ return current
28
+
29
+
30
+ class UnsupportedRemovePath(NotImplementedError):
31
+ """Raised when ``Remove`` targets a path that has no well-defined
32
+ deletion (currently: only the document root — ``""``/``"/"`` has no
33
+ parent container to update, so there's no comma/whitespace rule that
34
+ could apply). Everything else — array elements and, as of Mission 3
35
+ (Round 4 §2d's Deferred-1, closed 2026-07-30), object properties at any
36
+ depth — is supported.
37
+ """
38
+
39
+
40
+ def calculate_deletion_range(path: str, entry, parsed: Any, original_text: str) -> "tuple[int, int]":
41
+ """Compute the deletion range for a Remove, for either an array element
42
+ or an object property.
43
+
44
+ Resolves R3-2/R3-3/R3-8 (ADR-0013 Round 4 §2c) as one function: last-
45
+ element/property detection is parsed-aware and static per-op (never
46
+ adapts to what else is in the batch — that would reintroduce the
47
+ batch-aware coupling §2b deliberately avoids); the whitespace-consumption
48
+ rule always additionally claims the leading newline+indent run. This is
49
+ the freestanding function ``Remove.compile()``'s phantom 3-arg reference
50
+ (R3-8) was always missing a body for — it now has one, plus the
51
+ ``original_text`` parameter that reference didn't even name (needed for
52
+ the comma/whitespace regex analysis; see the module docstring note on
53
+ ``Operation.compile()``'s signature).
54
+
55
+ Array vs. object is decided from the PARENT'S ACTUAL TYPE in the
56
+ pristine parse, not the path's shape — a dict with an all-digit string
57
+ key (``{"3": ...}``, legal JSON) is handled correctly for free, rather
58
+ than needing a special case (Mission 3; previously this was rejected
59
+ outright, see git history for the earlier ``_ARRAY_INDEX_PATH``-based
60
+ dispatch this replaced).
61
+
62
+ For an object property, the range starts at the KEY (``entry.key_start``)
63
+ rather than the value — covering ``"key": value`` as one span — with the
64
+ exact same forward/backward comma-claim and whitespace-consumption rules
65
+ array elements use, keyed off "is this the last key in the object"
66
+ instead of "is this the last index in the array".
67
+ """
68
+ parent_path, key_or_index = path.rsplit("/", 1)
69
+ parent = resolve_path(parsed, parent_path) if parent_path else parsed
70
+
71
+ if isinstance(parent, list):
72
+ is_last = int(key_or_index) == len(parent) - 1
73
+ start = entry.value_start
74
+ else:
75
+ keys = list(parent.keys())
76
+ is_last = key_or_index == keys[-1]
77
+ start = entry.key_start
78
+
79
+ end = entry.value_end
80
+ if not is_last:
81
+ m = re.match(r"^(\s*,)", original_text[end:])
82
+ if m:
83
+ end += len(m.group(1))
84
+ else:
85
+ m = re.search(r",(\s*)$", original_text[:start])
86
+ if m:
87
+ start -= len(m.group(0))
88
+ m2 = re.search(r"(\r?\n[ \t]*)$", original_text[:start])
89
+ if m2:
90
+ start -= len(m2.group(1))
91
+ return start, end
92
+
93
+
94
+ @dataclass
95
+ class Operation(ABC):
96
+ """Base class for JSON Patch operations."""
97
+
98
+ path: str
99
+
100
+ @abstractmethod
101
+ def compile(self, source_map: SourceMap, parsed: Any, original_text: str) -> List[Edit]:
102
+ """Compile this operation into low-level Edits.
103
+
104
+ ``parsed`` is always the pristine, never-mutated parse (ADR-0013
105
+ Round 4 §1's coordinate-frame contract) — every path here resolves
106
+ in original-document coordinates, regardless of what else is in the
107
+ same batch. ``original_text`` is needed by ``Remove`` for the
108
+ comma/whitespace deletion-range analysis (§2c) — the ADR's original
109
+ two-arg ``compile(source_map, parsed)`` signature had no way to
110
+ supply this, which is part of why R3-8's ``calculate_deletion_range``
111
+ reference was left dangling; Round 4 §2c's corrected function takes
112
+ it explicitly, so this signature does too.
113
+ """
114
+
115
+
116
+ @dataclass
117
+ class Replace(Operation):
118
+ """Replace operation (JSON Patch ``replace``)."""
119
+
120
+ value: Any
121
+
122
+ def compile(self, source_map: SourceMap, parsed: Any, original_text: str) -> List[Edit]:
123
+ entry = source_map[self.path]
124
+ replacement = json.dumps(self.value, ensure_ascii=False) # R3-4
125
+ return [Edit(start=entry.value_start, end=entry.value_end, replacement=replacement, path=self.path)]
126
+
127
+
128
+ @dataclass
129
+ class Remove(Operation):
130
+ """Remove operation (JSON Patch ``remove``).
131
+
132
+ Supports both array elements and object properties, at any depth (as of
133
+ Mission 3, closing Round 4 §2d's Deferred-1). Only the document root has
134
+ no defined removal — see ``UnsupportedRemovePath``.
135
+ """
136
+
137
+ def compile(self, source_map: SourceMap, parsed: Any, original_text: str) -> List[Edit]:
138
+ if self.path in ("", "/"):
139
+ raise UnsupportedRemovePath(
140
+ f"Remove on {self.path!r} is not supported: the document root "
141
+ "has no parent container to update a comma/whitespace rule "
142
+ "against (see ADR-0013 Round 4 §2d)."
143
+ )
144
+
145
+ parent_path, _ = self.path.rsplit("/", 1)
146
+ parent = resolve_path(parsed, parent_path) if parent_path else parsed
147
+ if not isinstance(parent, (list, dict)):
148
+ raise UnsupportedRemovePath(
149
+ f"Remove on {self.path!r} is not supported: its parent "
150
+ f"({parent_path!r}) is not an array or object, so there is no "
151
+ "container to remove an element or property from "
152
+ "(see ADR-0013 Round 4 §2d)."
153
+ )
154
+
155
+ entry = source_map[self.path]
156
+ start, end = calculate_deletion_range(self.path, entry, parsed, original_text)
157
+ return [Edit(start=start, end=end, replacement="", path=self.path)]
158
+
159
+
160
+ @dataclass
161
+ class Add(Operation):
162
+ """Add operation — not yet implemented."""
163
+
164
+ value: Any
165
+
166
+ def compile(self, source_map: SourceMap, parsed: Any, original_text: str) -> List[Edit]:
167
+ raise NotImplementedError("Add operation not yet supported")
168
+
169
+
170
+ @dataclass
171
+ class Move(Operation):
172
+ """Move operation — not yet implemented."""
173
+
174
+ from_path: str
175
+ to_path: str
176
+
177
+ def compile(self, source_map: SourceMap, parsed: Any, original_text: str) -> List[Edit]:
178
+ raise NotImplementedError("Move operation not yet supported")
179
+
180
+
181
+ @dataclass
182
+ class Copy(Operation):
183
+ """Copy operation — not yet implemented."""
184
+
185
+ from_path: str
186
+ to_path: str
187
+
188
+ def compile(self, source_map: SourceMap, parsed: Any, original_text: str) -> List[Edit]:
189
+ raise NotImplementedError("Copy operation not yet supported")
190
+
191
+
192
+ @dataclass
193
+ class Test(Operation):
194
+ """Test operation (JSON Patch ``test``) — not yet implemented.
195
+
196
+ Named ``Test`` to match JSON Patch's own vocabulary, not a pytest test
197
+ class; ``__test__ = False`` tells pytest's collector to leave it alone.
198
+ """
199
+
200
+ __test__ = False
201
+ value: Any
202
+
203
+ def compile(self, source_map: SourceMap, parsed: Any, original_text: str) -> List[Edit]:
204
+ raise NotImplementedError("Test operation not yet supported")
@@ -0,0 +1,55 @@
1
+ """Source-map seam.
2
+
3
+ The only module in this package that imports the third-party `json_source_map`
4
+ library. Everything else calls `generate()` and works with the `SourceMap`/
5
+ `Entry` types defined here.
6
+
7
+ Per ADR-0013 Round 4 §4 (Cicerone's ack, condition #3): this is the documented
8
+ swap contract. If Phase 4 ever replaces the pinned PyPI dependency
9
+ (`json-source-map==1.0.5`) with an in-house tokenizer, only this file needs to
10
+ change, and the replacement must keep passing the three non-BMP fixtures in
11
+ `TEST-MATRIX-0013.md` §4.8 (`EDGE-4.8.1`-`EDGE-4.8.3`) unchanged.
12
+ """
13
+ from dataclasses import dataclass
14
+ from typing import Dict, Optional
15
+
16
+ import json_source_map as _json_source_map
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Entry:
21
+ """Position of one JSON value (and, for object properties, its key) in
22
+ the original source text.
23
+
24
+ All positions are Python code-point offsets (``str`` indices) — never
25
+ UTF-16 code units. ``value_end``/``key_end`` are exclusive: the character
26
+ at that index is the first character *after* the value/key.
27
+ """
28
+
29
+ value_start: int
30
+ value_end: int
31
+ key_start: Optional[int] = None
32
+ key_end: Optional[int] = None
33
+
34
+
35
+ SourceMap = Dict[str, Entry]
36
+
37
+
38
+ def generate(text: str) -> SourceMap:
39
+ """Compute the source map for a JSON document.
40
+
41
+ Assumes ``text`` is valid JSON — callers must parse first (so a malformed
42
+ document surfaces as ``json.JSONDecodeError`` from that parse, not from
43
+ here; the underlying library wraps decode errors in its own exception
44
+ type, which this seam deliberately never exposes).
45
+ """
46
+ raw = _json_source_map.calculate(text)
47
+ return {
48
+ path: Entry(
49
+ value_start=entry.value_start.position,
50
+ value_end=entry.value_end.position,
51
+ key_start=entry.key_start.position if entry.key_start is not None else None,
52
+ key_end=entry.key_end.position if entry.key_end is not None else None,
53
+ )
54
+ for path, entry in raw.items()
55
+ }
@@ -0,0 +1,156 @@
1
+ """Semantic post-condition validation (ADR-0013 R3-7c).
2
+
3
+ ``validate=True`` on ``JSONEditor.apply()``/``save()`` previously only
4
+ confirmed the result *parses* — not that it's semantically correct. A
5
+ position-computation bug can produce syntactically valid JSON that silently
6
+ replaced or removed the wrong thing.
7
+
8
+ Per Cicerone's Phase 2 resequencing (ADR-0013, `docs/inbox/2026-07-29-0719-cicerone-to-splicer-phase-1-ack-phase-2-go-validator-first.md`):
9
+ this is the tripwire the ADR's fallback-to-full-rewrite strategy depends on,
10
+ and it must exist *before* the CLI integration leans on that fallback in
11
+ production — a semantically wrong but syntactically valid result would
12
+ otherwise sail past the fallback trigger.
13
+ """
14
+ import copy
15
+ import json
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING, Any, List, Sequence, Tuple
18
+
19
+ from .operations import resolve_path
20
+
21
+ if TYPE_CHECKING: # avoid a circular import: editor.py imports this module
22
+ from .editor import Modification
23
+
24
+
25
+ @dataclass
26
+ class ValidationError:
27
+ """One semantic post-condition failure."""
28
+
29
+ path: str
30
+ reason: str
31
+
32
+
33
+ class SemanticValidationError(ValueError):
34
+ """Raised by :func:`validate_semantic_postconditions` (and by
35
+ ``JSONEditor.apply()``/``save()`` when ``validate=True``) if the result
36
+ parses but fails a semantic post-condition. A ``ValueError`` subclass so
37
+ it's caught by ``save()``'s existing broad ``except Exception`` and
38
+ triggers the fallback-to-full-rewrite path, same as any other apply-time
39
+ failure.
40
+ """
41
+
42
+ def __init__(self, errors: List[ValidationError]):
43
+ self.errors = errors
44
+ detail = "; ".join(f"{e.path}: {e.reason}" for e in errors)
45
+ super().__init__(f"Semantic validation failed: {detail}")
46
+
47
+
48
+ def _build_reference_result(original_parsed: Any, modifications: Sequence["Modification"]) -> Any:
49
+ """Reconstruct the "obviously correct" result by replaying every
50
+ modification against a deep copy using plain dict/list mutation — the
51
+ same kind of manipulation the ADR's rejected "full rewrite" alternative
52
+ would do. This is the reference the surgical edit's *fresh parse* gets
53
+ checked against.
54
+
55
+ Order matters and is deliberate: **replaces first, in original
56
+ coordinates** (no removal has shifted anything yet, so every replace
57
+ path resolves exactly like it would against the untouched original —
58
+ this is Round 4 §1's coordinate-frame contract, replayed here rather
59
+ than merely asserted), **then removes**, grouped by parent array and
60
+ applied in descending index order so an earlier ``pop()`` within the
61
+ same parent doesn't shift a later one out from under it.
62
+
63
+ (By the time this runs, ``apply_edits()`` has already succeeded — same-
64
+ path conflicts like R3-7(b) raise there, via the overlap check, before
65
+ this function is ever reached — so it doesn't need to detect them.)
66
+ """
67
+ result = copy.deepcopy(original_parsed)
68
+
69
+ for mod in modifications:
70
+ if mod.operation != "replace":
71
+ continue
72
+ parent_path, _, key = mod.path.rpartition("/")
73
+ parent = resolve_path(result, parent_path) if parent_path else result
74
+ if isinstance(parent, list):
75
+ parent[int(key)] = copy.deepcopy(mod.new_value)
76
+ else:
77
+ parent[key] = copy.deepcopy(mod.new_value)
78
+
79
+ removed_keys_by_parent: dict = {}
80
+ for mod in modifications:
81
+ if mod.operation != "remove":
82
+ continue
83
+ parent_path, _, key_or_index = mod.path.rpartition("/")
84
+ removed_keys_by_parent.setdefault(parent_path, []).append(key_or_index)
85
+
86
+ # Regression: Cicerone's retroactive Phase 2a finding (ADR-0013,
87
+ # `docs/inbox/2026-07-29-0738-cicerone-to-splicer-phase-2-ack-phase-3-go-and-a-confession.md`).
88
+ # Iterating removed_keys_by_parent in plain dict-insertion order breaks
89
+ # when one removal's parent path is itself nested under an element another
90
+ # removal's parent array also touches -- e.g. remove('/steps/1') recorded
91
+ # before remove('/steps/2/sub/0'): popping /steps index 1 first shifts
92
+ # what was originally /steps/2 down to /steps/1, so resolving
93
+ # "/steps/2/sub" afterward reaches the WRONG element (originally /steps/3's
94
+ # sub, not /steps/2's). Deepest-parent-first avoids this: any deeper path
95
+ # is either unrelated to a shallower one (order doesn't matter) or nested
96
+ # under it (and must be resolved before the shallower pop shifts it).
97
+ #
98
+ # Mission 3 (object-property Remove, closing Round 4 S2d's Deferred-1):
99
+ # a parent can now be a list OR a dict, and the two need different
100
+ # removal semantics -- list indices must be removed in descending order
101
+ # (an earlier pop() shifts later indices), but dict keys don't shift each
102
+ # other at all, so plain deletion in any order is fine.
103
+ for parent_path, keys_or_indices in sorted(
104
+ removed_keys_by_parent.items(), key=lambda item: item[0].count("/"), reverse=True
105
+ ):
106
+ parent = resolve_path(result, parent_path) if parent_path else result
107
+ if isinstance(parent, list):
108
+ for idx in sorted({int(k) for k in keys_or_indices}, reverse=True):
109
+ parent.pop(idx)
110
+ else:
111
+ for key in set(keys_or_indices):
112
+ del parent[key]
113
+
114
+ return result
115
+
116
+
117
+ def _diff_paths(expected: Any, actual: Any, path: str = "") -> List[Tuple[str, str]]:
118
+ """Find JSON-pointer paths where ``expected`` and ``actual`` structurally
119
+ differ, for diagnostic error messages (not just a whole-structure dump)."""
120
+ if isinstance(expected, dict) and isinstance(actual, dict):
121
+ diffs = []
122
+ for key in sorted(set(expected) | set(actual)):
123
+ child_path = f"{path}/{key}"
124
+ if key not in expected:
125
+ diffs.append((child_path, f"unexpected key (not in the expected result)"))
126
+ elif key not in actual:
127
+ diffs.append((child_path, "missing (present in the expected result)"))
128
+ else:
129
+ diffs.extend(_diff_paths(expected[key], actual[key], child_path))
130
+ return diffs
131
+ if isinstance(expected, list) and isinstance(actual, list):
132
+ if len(expected) != len(actual):
133
+ return [(path, f"array length mismatch: expected {len(expected)}, got {len(actual)}")]
134
+ diffs = []
135
+ for i, (e, a) in enumerate(zip(expected, actual)):
136
+ diffs.extend(_diff_paths(e, a, f"{path}/{i}"))
137
+ return diffs
138
+ if expected != actual:
139
+ return [(path, f"expected {expected!r}, got {actual!r}")]
140
+ return []
141
+
142
+
143
+ def validate_semantic_postconditions(
144
+ modifications: Sequence["Modification"], original_parsed: Any, modified_text: str
145
+ ) -> List[ValidationError]:
146
+ """Check R3-7c's post-conditions: replay every recorded modification
147
+ against a deep copy of the pristine original (plain dict/list mutation,
148
+ not the surgical text editor) to build the expected result, then
149
+ structurally diff it against a *fresh* parse of what the surgical edit
150
+ actually produced. Any difference is a real semantic bug — most likely a
151
+ position-computation error — that a syntax-only ``json.loads()`` check
152
+ would never catch.
153
+ """
154
+ fresh_parsed = json.loads(modified_text)
155
+ expected = _build_reference_result(original_parsed, modifications)
156
+ return [ValidationError(path, reason) for path, reason in _diff_paths(expected, fresh_parsed)]
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.4
2
+ Name: json-source-edit
3
+ Version: 0.1.0
4
+ Summary: Surgical, byte-preserving JSON editing — change only what you intend to change.
5
+ Author: Jérémie Lumbroso
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jlumbroso/json-source-edit
8
+ Project-URL: Repository, https://github.com/jlumbroso/json-source-edit
9
+ Project-URL: Provenance, https://github.com/jlumbroso/json-source-edit/blob/main/PROVENANCE.md
10
+ Keywords: json,json-patch,diff,text-editing,format-preserving
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Topic :: Text Processing :: Markup
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: json-source-map>=1.0.5
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: pytest-cov; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # json-source-edit
31
+
32
+ A Python package for **surgical, byte-preserving JSON editing.** Change
33
+ only what you intend to change — everything else in the file survives
34
+ byte-for-byte, so your diff shows your edit, not a full-file rewrite.
35
+
36
+ ## The problem
37
+
38
+ The obvious way to edit a JSON file is parse → modify → serialize:
39
+
40
+ ```python
41
+ data = json.loads(text)
42
+ data["steps"][1]["line"] = 433
43
+ text = json.dumps(data, indent=2)
44
+ ```
45
+
46
+ This is correct, and it destroys your diff. Real evidence, from the project
47
+ this package was extracted out of: three simple changes to one file — two
48
+ field updates and one deletion — were *expected* to produce a ~10-line diff.
49
+ The parse-modify-serialize round-trip produced a **500+ line diff** instead.
50
+
51
+ That's because `json.dumps` doesn't know or care what your original
52
+ formatting was — indentation, key order, quote style, all of it gets
53
+ re-decided from scratch, every time, whether or not you touched it.
54
+ Multiply that by a codebase-sized JSON file and code review becomes
55
+ impossible: every line looks changed, and the actual edit is buried.
56
+
57
+ `json-source-edit` fixes this at the source: it never re-serializes the
58
+ document. It computes exact byte positions for each change against the
59
+ *original text* and splices replacement bytes into the gaps — the same
60
+ operation a film or tape splicer performs, cut and rejoin at an exact point,
61
+ everything else on the reel undisturbed.
62
+
63
+ ## Install
64
+
65
+ Not yet on PyPI (see [Status](#status) below). Until then:
66
+
67
+ ```bash
68
+ git clone https://github.com/jlumbroso/json-source-edit
69
+ cd json-source-edit
70
+ pip install -e .
71
+ ```
72
+
73
+ ## Usage
74
+
75
+ ```python
76
+ from json_source_edit import JSONEditor
77
+
78
+ editor = JSONEditor.from_string("""{
79
+ "title": "Rust Basics",
80
+ "steps": [
81
+ {"file": "src/main.rs", "line": 10},
82
+ {"file": "src/lib.rs", "line": 20}
83
+ ]
84
+ }""")
85
+ editor.replace("/steps/0/line", 433)
86
+ editor.remove("/steps/1")
87
+ result = editor.apply(validate=True)
88
+ print(editor.preview_diff())
89
+ ```
90
+
91
+ ```diff
92
+ --- original
93
+ +++ modified
94
+ @@ -1,7 +1,6 @@
95
+ {
96
+ "title": "Rust Basics",
97
+ "steps": [
98
+ - {"file": "src/main.rs", "line": 10},
99
+ - {"file": "src/lib.rs", "line": 20}
100
+ + {"file": "src/main.rs", "line": 433}
101
+ ]
102
+ }
103
+ ```
104
+
105
+ `validate=True` catches mistakes before they reach you. It reconstructs the
106
+ expected result independently — replaying your edits against a copy of the
107
+ parsed document — and diffs that against what the surgical edit actually
108
+ produced. If they disagree, it raises `SemanticValidationError` instead of
109
+ silently returning something subtly wrong.
110
+
111
+ `apply()` defaults to `validate=False`; `save()` defaults to `validate=True`
112
+ — writing to disk is where a mistake actually costs you something, so that
113
+ path checks unless you opt out.
114
+
115
+ Three more methods help you inspect a pending batch before committing to
116
+ it: `get_value`, `get_modifications`, and `preview_diff`.
117
+
118
+ ## Scope
119
+
120
+ | JSON Patch operation | Status |
121
+ |---|---|
122
+ | `replace` | Supported — any path, any depth |
123
+ | `remove` | Supported — array elements and object properties, any depth, except the document root itself (structurally undefined: the root has no parent container to apply a comma/whitespace rule against) |
124
+ | `add`, `move`, `copy`, `test` | Not implemented — raise `NotImplementedError` |
125
+
126
+ Multiple `replace`/`remove` calls batch correctly against the same original
127
+ document — every path resolves in original-document coordinates regardless
128
+ of what else is in the batch, so edit order doesn't matter and one deletion
129
+ can't corrupt another edit's position.
130
+
131
+ ## Benchmark (v0)
132
+
133
+ Reproduce with `python benchmarks/throughput.py` — a standalone script, no
134
+ test framework required.
135
+
136
+ Methodology: replaces 5% of a flat array's elements at each size, median of
137
+ 5 runs, reporting throughput against the *original* document size (the more
138
+ relevant number for "can this handle my file" than edit count alone).
139
+
140
+ Single-machine numbers, not a controlled benchmark environment — expect
141
+ real variance run to run and machine to machine; rerun locally before
142
+ relying on any of this for capacity planning.
143
+
144
+ | Size | Elements | Document | Edits | Median time | Throughput |
145
+ |---|---|---|---|---|---|
146
+ | small | 200 | 0.01MB | 10 | 0.65ms | 15,353 edits/sec, 13.7 MB/sec |
147
+ | medium | 2,000 | 0.09MB | 100 | 6.25ms | 15,990 edits/sec, 14.9 MB/sec |
148
+ | large | 20,000 | 0.97MB | 1000 | 87.22ms | 11,465 edits/sec, 11.1 MB/sec |
149
+
150
+ (Measured on macOS/arm64, Python 3.12.4, 2026-08-01 — see the script's own
151
+ output for full per-run samples; the "large" row in particular showed
152
+ bimodal timing on this machine, a real observation, not smoothed over.)
153
+
154
+ ## Provenance
155
+
156
+ This package was designed and hardened inside
157
+ [`codetour-cli`](https://github.com/jlumbroso/codetour-cli), across four
158
+ independent review gates, before being extracted here with no known
159
+ remaining incompleteness. See [`PROVENANCE.md`](PROVENANCE.md) for the
160
+ commit-by-commit history and [`docs/adr/0001-extraction-from-codetour-cli.md`](docs/adr/0001-extraction-from-codetour-cli.md)
161
+ for the extraction decisions themselves (import strategy, the dependency
162
+ verdict, the supported-Python-versions floor).
163
+
164
+ ## Credits
165
+
166
+ The position-mapping this package edits against — turning a JSON document
167
+ into exact byte offsets for every value and key — is done by
168
+ [`json-source-map`](https://pypi.org/project/json-source-map/), a small,
169
+ dependency-free library by David Andersson. It's the one piece of this
170
+ package that isn't ours: everything in `src/json_source_edit/source_map.py`
171
+ is a thin seam around it, and everything else in this package builds on top
172
+ of what it computes. Elegant, narrowly-scoped libraries like this are what
173
+ make a package like this one feasible to write at all.
174
+
175
+ ## Status
176
+
177
+ Pre-release. 187 tests, 100% line coverage. Not yet on PyPI — CI is scaffolded
178
+ (GitHub Actions test matrix, Python 3.8–3.13) but the publish workflow is
179
+ deliberately inert pending the repo owner's two-step activation (see
180
+ `.github/workflows/publish.yml`).
181
+
182
+ ## License
183
+
184
+ MIT.
@@ -0,0 +1,11 @@
1
+ json_source_edit/__init__.py,sha256=F3_1rJmu15FJkfelmzbAmEaxso5w7T1YTibbifQRKN4,516
2
+ json_source_edit/algorithm.py,sha256=RtQXYpXrSh1XOuIRpGR20f9BqMZh_lf5vh-KU7cKTtM,4396
3
+ json_source_edit/editor.py,sha256=pOnFnDxwXzixFJT58jdBGb455U9e9ljhgf7vy5-q77E,9883
4
+ json_source_edit/operations.py,sha256=jh4qvZR4aRF9FkaDI8QVNvmWTCmnSoh0BjLcMN8BWXA,7872
5
+ json_source_edit/source_map.py,sha256=omt7Ux5P8jW-bFgeUck6aRw9S-sC_bdm7psl1g7HU_4,1992
6
+ json_source_edit/validator.py,sha256=YNfjKWH56Allcrv47q0FczhgmOfz00fLfVUsLYCMbp4,7436
7
+ json_source_edit-0.1.0.dist-info/licenses/LICENSE,sha256=wG31d7hCq0RBhofxVSp9vS4KgrfFG0vIGyUcoH-HjUo,1075
8
+ json_source_edit-0.1.0.dist-info/METADATA,sha256=zSf4BYQ1msx2mkdMy96bWCd0xFl1_P8Hs0V_8GuVSSQ,7221
9
+ json_source_edit-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
10
+ json_source_edit-0.1.0.dist-info/top_level.txt,sha256=8gzUtf0k5nHst4t-V-3inkUaOmElUL_5vu4dWKcARj0,17
11
+ json_source_edit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jérémie Lumbroso
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ json_source_edit