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.
- json_source_edit/__init__.py +16 -0
- json_source_edit/algorithm.py +119 -0
- json_source_edit/editor.py +240 -0
- json_source_edit/operations.py +204 -0
- json_source_edit/source_map.py +55 -0
- json_source_edit/validator.py +156 -0
- json_source_edit-0.1.0.dist-info/METADATA +184 -0
- json_source_edit-0.1.0.dist-info/RECORD +11 -0
- json_source_edit-0.1.0.dist-info/WHEEL +5 -0
- json_source_edit-0.1.0.dist-info/licenses/LICENSE +21 -0
- json_source_edit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|