json-source-edit 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- json_source_edit-0.1.0/PKG-INFO +184 -0
- json_source_edit-0.1.0/README.md +155 -0
- json_source_edit-0.1.0/pyproject.toml +45 -0
- json_source_edit-0.1.0/setup.cfg +4 -0
- json_source_edit-0.1.0/src/json_source_edit/__init__.py +16 -0
- json_source_edit-0.1.0/src/json_source_edit/algorithm.py +119 -0
- json_source_edit-0.1.0/src/json_source_edit/editor.py +240 -0
- json_source_edit-0.1.0/src/json_source_edit/operations.py +204 -0
- json_source_edit-0.1.0/src/json_source_edit/source_map.py +55 -0
- json_source_edit-0.1.0/src/json_source_edit/validator.py +156 -0
- json_source_edit-0.1.0/src/json_source_edit.egg-info/PKG-INFO +184 -0
- json_source_edit-0.1.0/src/json_source_edit.egg-info/SOURCES.txt +23 -0
- json_source_edit-0.1.0/src/json_source_edit.egg-info/dependency_links.txt +1 -0
- json_source_edit-0.1.0/src/json_source_edit.egg-info/requires.txt +5 -0
- json_source_edit-0.1.0/src/json_source_edit.egg-info/top_level.txt +1 -0
- json_source_edit-0.1.0/tests/test_algorithm.py +247 -0
- json_source_edit-0.1.0/tests/test_edge_cases.py +224 -0
- json_source_edit-0.1.0/tests/test_editor.py +385 -0
- json_source_edit-0.1.0/tests/test_errors.py +89 -0
- json_source_edit-0.1.0/tests/test_format_preservation.py +241 -0
- json_source_edit-0.1.0/tests/test_operations.py +210 -0
- json_source_edit-0.1.0/tests/test_performance.py +148 -0
- json_source_edit-0.1.0/tests/test_source_map.py +52 -0
- json_source_edit-0.1.0/tests/test_validator.py +201 -0
|
@@ -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,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,155 @@
|
|
|
1
|
+
# json-source-edit
|
|
2
|
+
|
|
3
|
+
A Python package for **surgical, byte-preserving JSON editing.** Change
|
|
4
|
+
only what you intend to change — everything else in the file survives
|
|
5
|
+
byte-for-byte, so your diff shows your edit, not a full-file rewrite.
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
The obvious way to edit a JSON file is parse → modify → serialize:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
data = json.loads(text)
|
|
13
|
+
data["steps"][1]["line"] = 433
|
|
14
|
+
text = json.dumps(data, indent=2)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
This is correct, and it destroys your diff. Real evidence, from the project
|
|
18
|
+
this package was extracted out of: three simple changes to one file — two
|
|
19
|
+
field updates and one deletion — were *expected* to produce a ~10-line diff.
|
|
20
|
+
The parse-modify-serialize round-trip produced a **500+ line diff** instead.
|
|
21
|
+
|
|
22
|
+
That's because `json.dumps` doesn't know or care what your original
|
|
23
|
+
formatting was — indentation, key order, quote style, all of it gets
|
|
24
|
+
re-decided from scratch, every time, whether or not you touched it.
|
|
25
|
+
Multiply that by a codebase-sized JSON file and code review becomes
|
|
26
|
+
impossible: every line looks changed, and the actual edit is buried.
|
|
27
|
+
|
|
28
|
+
`json-source-edit` fixes this at the source: it never re-serializes the
|
|
29
|
+
document. It computes exact byte positions for each change against the
|
|
30
|
+
*original text* and splices replacement bytes into the gaps — the same
|
|
31
|
+
operation a film or tape splicer performs, cut and rejoin at an exact point,
|
|
32
|
+
everything else on the reel undisturbed.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Not yet on PyPI (see [Status](#status) below). Until then:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
git clone https://github.com/jlumbroso/json-source-edit
|
|
40
|
+
cd json-source-edit
|
|
41
|
+
pip install -e .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from json_source_edit import JSONEditor
|
|
48
|
+
|
|
49
|
+
editor = JSONEditor.from_string("""{
|
|
50
|
+
"title": "Rust Basics",
|
|
51
|
+
"steps": [
|
|
52
|
+
{"file": "src/main.rs", "line": 10},
|
|
53
|
+
{"file": "src/lib.rs", "line": 20}
|
|
54
|
+
]
|
|
55
|
+
}""")
|
|
56
|
+
editor.replace("/steps/0/line", 433)
|
|
57
|
+
editor.remove("/steps/1")
|
|
58
|
+
result = editor.apply(validate=True)
|
|
59
|
+
print(editor.preview_diff())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```diff
|
|
63
|
+
--- original
|
|
64
|
+
+++ modified
|
|
65
|
+
@@ -1,7 +1,6 @@
|
|
66
|
+
{
|
|
67
|
+
"title": "Rust Basics",
|
|
68
|
+
"steps": [
|
|
69
|
+
- {"file": "src/main.rs", "line": 10},
|
|
70
|
+
- {"file": "src/lib.rs", "line": 20}
|
|
71
|
+
+ {"file": "src/main.rs", "line": 433}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`validate=True` catches mistakes before they reach you. It reconstructs the
|
|
77
|
+
expected result independently — replaying your edits against a copy of the
|
|
78
|
+
parsed document — and diffs that against what the surgical edit actually
|
|
79
|
+
produced. If they disagree, it raises `SemanticValidationError` instead of
|
|
80
|
+
silently returning something subtly wrong.
|
|
81
|
+
|
|
82
|
+
`apply()` defaults to `validate=False`; `save()` defaults to `validate=True`
|
|
83
|
+
— writing to disk is where a mistake actually costs you something, so that
|
|
84
|
+
path checks unless you opt out.
|
|
85
|
+
|
|
86
|
+
Three more methods help you inspect a pending batch before committing to
|
|
87
|
+
it: `get_value`, `get_modifications`, and `preview_diff`.
|
|
88
|
+
|
|
89
|
+
## Scope
|
|
90
|
+
|
|
91
|
+
| JSON Patch operation | Status |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `replace` | Supported — any path, any depth |
|
|
94
|
+
| `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) |
|
|
95
|
+
| `add`, `move`, `copy`, `test` | Not implemented — raise `NotImplementedError` |
|
|
96
|
+
|
|
97
|
+
Multiple `replace`/`remove` calls batch correctly against the same original
|
|
98
|
+
document — every path resolves in original-document coordinates regardless
|
|
99
|
+
of what else is in the batch, so edit order doesn't matter and one deletion
|
|
100
|
+
can't corrupt another edit's position.
|
|
101
|
+
|
|
102
|
+
## Benchmark (v0)
|
|
103
|
+
|
|
104
|
+
Reproduce with `python benchmarks/throughput.py` — a standalone script, no
|
|
105
|
+
test framework required.
|
|
106
|
+
|
|
107
|
+
Methodology: replaces 5% of a flat array's elements at each size, median of
|
|
108
|
+
5 runs, reporting throughput against the *original* document size (the more
|
|
109
|
+
relevant number for "can this handle my file" than edit count alone).
|
|
110
|
+
|
|
111
|
+
Single-machine numbers, not a controlled benchmark environment — expect
|
|
112
|
+
real variance run to run and machine to machine; rerun locally before
|
|
113
|
+
relying on any of this for capacity planning.
|
|
114
|
+
|
|
115
|
+
| Size | Elements | Document | Edits | Median time | Throughput |
|
|
116
|
+
|---|---|---|---|---|---|
|
|
117
|
+
| small | 200 | 0.01MB | 10 | 0.65ms | 15,353 edits/sec, 13.7 MB/sec |
|
|
118
|
+
| medium | 2,000 | 0.09MB | 100 | 6.25ms | 15,990 edits/sec, 14.9 MB/sec |
|
|
119
|
+
| large | 20,000 | 0.97MB | 1000 | 87.22ms | 11,465 edits/sec, 11.1 MB/sec |
|
|
120
|
+
|
|
121
|
+
(Measured on macOS/arm64, Python 3.12.4, 2026-08-01 — see the script's own
|
|
122
|
+
output for full per-run samples; the "large" row in particular showed
|
|
123
|
+
bimodal timing on this machine, a real observation, not smoothed over.)
|
|
124
|
+
|
|
125
|
+
## Provenance
|
|
126
|
+
|
|
127
|
+
This package was designed and hardened inside
|
|
128
|
+
[`codetour-cli`](https://github.com/jlumbroso/codetour-cli), across four
|
|
129
|
+
independent review gates, before being extracted here with no known
|
|
130
|
+
remaining incompleteness. See [`PROVENANCE.md`](PROVENANCE.md) for the
|
|
131
|
+
commit-by-commit history and [`docs/adr/0001-extraction-from-codetour-cli.md`](docs/adr/0001-extraction-from-codetour-cli.md)
|
|
132
|
+
for the extraction decisions themselves (import strategy, the dependency
|
|
133
|
+
verdict, the supported-Python-versions floor).
|
|
134
|
+
|
|
135
|
+
## Credits
|
|
136
|
+
|
|
137
|
+
The position-mapping this package edits against — turning a JSON document
|
|
138
|
+
into exact byte offsets for every value and key — is done by
|
|
139
|
+
[`json-source-map`](https://pypi.org/project/json-source-map/), a small,
|
|
140
|
+
dependency-free library by David Andersson. It's the one piece of this
|
|
141
|
+
package that isn't ours: everything in `src/json_source_edit/source_map.py`
|
|
142
|
+
is a thin seam around it, and everything else in this package builds on top
|
|
143
|
+
of what it computes. Elegant, narrowly-scoped libraries like this are what
|
|
144
|
+
make a package like this one feasible to write at all.
|
|
145
|
+
|
|
146
|
+
## Status
|
|
147
|
+
|
|
148
|
+
Pre-release. 187 tests, 100% line coverage. Not yet on PyPI — CI is scaffolded
|
|
149
|
+
(GitHub Actions test matrix, Python 3.8–3.13) but the publish workflow is
|
|
150
|
+
deliberately inert pending the repo owner's two-step activation (see
|
|
151
|
+
`.github/workflows/publish.yml`).
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "json-source-edit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Surgical, byte-preserving JSON editing — change only what you intend to change."
|
|
9
|
+
authors = [{name = "Jérémie Lumbroso"}]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
license = {text = "MIT"}
|
|
13
|
+
keywords = ["json", "json-patch", "diff", "text-editing", "format-preserving"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
18
|
+
"Topic :: Text Processing :: Markup",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.8",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"json-source-map>=1.0.5",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest",
|
|
33
|
+
"pytest-cov",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/jlumbroso/json-source-edit"
|
|
38
|
+
Repository = "https://github.com/jlumbroso/json-source-edit"
|
|
39
|
+
Provenance = "https://github.com/jlumbroso/json-source-edit/blob/main/PROVENANCE.md"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
@@ -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)
|