json-source-edit 0.1.0__tar.gz → 0.2.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.
Files changed (25) hide show
  1. {json_source_edit-0.1.0/src/json_source_edit.egg-info → json_source_edit-0.2.0}/PKG-INFO +14 -15
  2. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/README.md +13 -14
  3. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/pyproject.toml +1 -1
  4. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/src/json_source_edit/__init__.py +2 -1
  5. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/src/json_source_edit/editor.py +15 -1
  6. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/src/json_source_edit/operations.py +52 -2
  7. {json_source_edit-0.1.0 → json_source_edit-0.2.0/src/json_source_edit.egg-info}/PKG-INFO +14 -15
  8. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/tests/test_operations.py +95 -2
  9. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/LICENSE +0 -0
  10. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/setup.cfg +0 -0
  11. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/src/json_source_edit/algorithm.py +0 -0
  12. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/src/json_source_edit/source_map.py +0 -0
  13. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/src/json_source_edit/validator.py +0 -0
  14. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/src/json_source_edit.egg-info/SOURCES.txt +0 -0
  15. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/src/json_source_edit.egg-info/dependency_links.txt +0 -0
  16. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/src/json_source_edit.egg-info/requires.txt +0 -0
  17. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/src/json_source_edit.egg-info/top_level.txt +0 -0
  18. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/tests/test_algorithm.py +0 -0
  19. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/tests/test_edge_cases.py +0 -0
  20. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/tests/test_editor.py +0 -0
  21. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/tests/test_errors.py +0 -0
  22. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/tests/test_format_preservation.py +0 -0
  23. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/tests/test_performance.py +0 -0
  24. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/tests/test_source_map.py +0 -0
  25. {json_source_edit-0.1.0 → json_source_edit-0.2.0}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json-source-edit
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Surgical, byte-preserving JSON editing — change only what you intend to change.
5
5
  Author: Jérémie Lumbroso
6
6
  License: MIT
@@ -62,14 +62,12 @@ everything else on the reel undisturbed.
62
62
 
63
63
  ## Install
64
64
 
65
- Not yet on PyPI (see [Status](#status) below). Until then:
66
-
67
65
  ```bash
68
- git clone https://github.com/jlumbroso/json-source-edit
69
- cd json-source-edit
70
- pip install -e .
66
+ pip install json-source-edit
71
67
  ```
72
68
 
69
+ (Or clone the repo and `pip install -e .` for local development.)
70
+
73
71
  ## Usage
74
72
 
75
73
  ```python
@@ -121,12 +119,13 @@ it: `get_value`, `get_modifications`, and `preview_diff`.
121
119
  |---|---|
122
120
  | `replace` | Supported — any path, any depth |
123
121
  | `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` |
122
+ | `test` | Supported — not a text edit, an assertion. Evaluated against the document as it stood before this batch's own operations (not a naive sequential reading of RFC 6902 — see the docstring on `operations.Test`), with type-strict comparison (`1` does not test-equal `1.0` or `true`) |
123
+ | `add`, `move`, `copy` | Not implemented — raise `NotImplementedError`. Planned next (see `CHANGELOG.md`) |
125
124
 
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.
125
+ Multiple `replace`/`remove`/`test` calls batch correctly against the same
126
+ original document — every path resolves in original-document coordinates
127
+ regardless of what else is in the batch, so edit order doesn't matter and
128
+ one deletion can't corrupt another edit's position.
130
129
 
131
130
  ## Benchmark (v0)
132
131
 
@@ -174,10 +173,10 @@ make a package like this one feasible to write at all.
174
173
 
175
174
  ## Status
176
175
 
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`).
176
+ Published: [pypi.org/project/json-source-edit](https://pypi.org/project/json-source-edit/).
177
+ `pip install json-source-edit`. 203 tests, 100% line coverage. See
178
+ `CHANGELOG.md` for release history and the Scope table above for what's
179
+ implemented.
181
180
 
182
181
  ## License
183
182
 
@@ -33,14 +33,12 @@ everything else on the reel undisturbed.
33
33
 
34
34
  ## Install
35
35
 
36
- Not yet on PyPI (see [Status](#status) below). Until then:
37
-
38
36
  ```bash
39
- git clone https://github.com/jlumbroso/json-source-edit
40
- cd json-source-edit
41
- pip install -e .
37
+ pip install json-source-edit
42
38
  ```
43
39
 
40
+ (Or clone the repo and `pip install -e .` for local development.)
41
+
44
42
  ## Usage
45
43
 
46
44
  ```python
@@ -92,12 +90,13 @@ it: `get_value`, `get_modifications`, and `preview_diff`.
92
90
  |---|---|
93
91
  | `replace` | Supported — any path, any depth |
94
92
  | `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` |
93
+ | `test` | Supported — not a text edit, an assertion. Evaluated against the document as it stood before this batch's own operations (not a naive sequential reading of RFC 6902 — see the docstring on `operations.Test`), with type-strict comparison (`1` does not test-equal `1.0` or `true`) |
94
+ | `add`, `move`, `copy` | Not implemented — raise `NotImplementedError`. Planned next (see `CHANGELOG.md`) |
96
95
 
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.
96
+ Multiple `replace`/`remove`/`test` calls batch correctly against the same
97
+ original document — every path resolves in original-document coordinates
98
+ regardless of what else is in the batch, so edit order doesn't matter and
99
+ one deletion can't corrupt another edit's position.
101
100
 
102
101
  ## Benchmark (v0)
103
102
 
@@ -145,10 +144,10 @@ make a package like this one feasible to write at all.
145
144
 
146
145
  ## Status
147
146
 
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`).
147
+ Published: [pypi.org/project/json-source-edit](https://pypi.org/project/json-source-edit/).
148
+ `pip install json-source-edit`. 203 tests, 100% line coverage. See
149
+ `CHANGELOG.md` for release history and the Scope table above for what's
150
+ implemented.
152
151
 
153
152
  ## License
154
153
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "json-source-edit"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Surgical, byte-preserving JSON editing — change only what you intend to change."
9
9
  authors = [{name = "Jérémie Lumbroso"}]
10
10
  readme = "README.md"
@@ -5,12 +5,13 @@ byte-for-byte." See ADR-0013 in the parent codetour-cli project
5
5
  (``docs/adr/0013-format-preserving-json-editing.md``) for the design.
6
6
  """
7
7
  from .editor import JSONEditor, Modification
8
- from .operations import UnsupportedRemovePath
8
+ from .operations import TestOperationFailed, UnsupportedRemovePath
9
9
  from .validator import SemanticValidationError
10
10
 
11
11
  __all__ = [
12
12
  "JSONEditor",
13
13
  "Modification",
14
14
  "UnsupportedRemovePath",
15
+ "TestOperationFailed",
15
16
  "SemanticValidationError",
16
17
  ]
@@ -16,7 +16,7 @@ from pathlib import Path
16
16
  from typing import Any, Dict, List, Optional, Union
17
17
 
18
18
  from .algorithm import apply_edits, coalesce_deletions, strip_orphaned_trailing_commas
19
- from .operations import Operation, Remove, Replace, resolve_path
19
+ from .operations import Operation, Remove, Replace, Test, resolve_path
20
20
  from .source_map import SourceMap
21
21
  from .validator import SemanticValidationError, validate_semantic_postconditions
22
22
  from .source_map import generate as generate_source_map
@@ -90,6 +90,17 @@ class JSONEditor:
90
90
  self.operations.append(Remove(path))
91
91
  self.modifications.append(Modification("remove", path, old_value, None))
92
92
 
93
+ def test(self, path: str, value: Any) -> None:
94
+ """Record an assertion that ``path`` currently equals ``value``
95
+ (does not apply yet). Evaluated at compile time against the
96
+ pristine, pre-batch parse — see ``operations.Test`` for the full
97
+ semantics and the type-strict comparison rule. Raises no
98
+ ``Modification``: a passing test doesn't change the document, and
99
+ ``get_modifications()``'s audit trail is specifically about what
100
+ changed.
101
+ """
102
+ self.operations.append(Test(path, value))
103
+
93
104
  # Backwards-compatible aliases matching the ADR's original API.
94
105
  def set_value(self, path: str, value: Any) -> None:
95
106
  """Alias for :meth:`replace`."""
@@ -147,6 +158,9 @@ class JSONEditor:
147
158
  surgical edit.
148
159
  operations.UnsupportedRemovePath: if any recorded ``remove()``
149
160
  targets an object-property path (Round 4 §2d).
161
+ operations.TestOperationFailed: if any recorded ``test()``'s
162
+ expected value doesn't match — raised before any edit in
163
+ the batch is applied, so nothing partial ever lands.
150
164
  ValueError: if two edits overlap after coalescing.
151
165
  """
152
166
  edits = self._compile_edits()
@@ -37,6 +37,21 @@ class UnsupportedRemovePath(NotImplementedError):
37
37
  """
38
38
 
39
39
 
40
+ class TestOperationFailed(Exception):
41
+ """Raised when a ``Test`` operation's expected value doesn't match the
42
+ document — JSON Patch ``test`` semantics: a failing test aborts the
43
+ whole batch. Raised during ``compile()``, which runs entirely before
44
+ ``apply_edits()`` ever splices text, so a failing test guarantees
45
+ nothing in the batch — not even edits queued before it — gets applied.
46
+
47
+ ``__test__ = False``: same reason as ``Test`` itself — the name starts
48
+ with "Test", and pytest's collector would otherwise try (and fail) to
49
+ treat this exception class as a test class.
50
+ """
51
+
52
+ __test__ = False
53
+
54
+
40
55
  def calculate_deletion_range(path: str, entry, parsed: Any, original_text: str) -> "tuple[int, int]":
41
56
  """Compute the deletion range for a Remove, for either an array element
42
57
  or an object property.
@@ -191,14 +206,49 @@ class Copy(Operation):
191
206
 
192
207
  @dataclass
193
208
  class Test(Operation):
194
- """Test operation (JSON Patch ``test``) not yet implemented.
209
+ """Test operation (JSON Patch ``test``): assert the value at ``path``
210
+ equals ``value``. Not a text edit — contributes no ``Edit`` objects on
211
+ success; raises ``TestOperationFailed`` on mismatch.
195
212
 
196
213
  Named ``Test`` to match JSON Patch's own vocabulary, not a pytest test
197
214
  class; ``__test__ = False`` tells pytest's collector to leave it alone.
215
+
216
+ **Evaluated against the pristine, pre-batch parse** (Round 4 §1's
217
+ coordinate-frame contract — the same rule ``Replace``/``Remove`` follow),
218
+ not against the effect of any earlier operation in the same batch. This
219
+ is a real, deliberate deviation from a naive reading of RFC 6902's
220
+ sequential-application model, where a `test` following a `replace` on
221
+ the same path would see the replace's effect. Batching several
222
+ operations in this package works *because* every operation resolves
223
+ independently against fixed original-document coordinates — extending
224
+ that same guarantee to ``Test`` keeps the whole batch model coherent,
225
+ at the cost of not supporting "test the value my own batch just set."
226
+ Call ``apply()``/``save()`` once per patch if you need that.
227
+
228
+ **Comparison is type-strict**, not Python's own loose ``==``: ``1`` does
229
+ not test-equal ``1.0``, and ``true`` does not test-equal ``1`` — Python's
230
+ ``bool`` being an ``int`` subclass would otherwise make
231
+ ``test(path, True)`` silently pass against a stored ``1``. A package
232
+ whose whole purpose is preserving a document's exact original
233
+ representation should not blur exactly this distinction in the one
234
+ operation whose entire job is comparison.
198
235
  """
199
236
 
200
237
  __test__ = False
201
238
  value: Any
202
239
 
203
240
  def compile(self, source_map: SourceMap, parsed: Any, original_text: str) -> List[Edit]:
204
- raise NotImplementedError("Test operation not yet supported")
241
+ try:
242
+ actual = resolve_path(parsed, self.path)
243
+ except (KeyError, IndexError, ValueError):
244
+ raise TestOperationFailed(
245
+ f"Test on {self.path!r} failed: path does not exist in the document."
246
+ ) from None
247
+ if type(actual) is not type(self.value) or actual != self.value:
248
+ raise TestOperationFailed(
249
+ f"Test on {self.path!r} failed: expected {self.value!r} "
250
+ f"({type(self.value).__name__}), found {actual!r} "
251
+ f"({type(actual).__name__}) in the document as it stood "
252
+ "before this batch's own operations."
253
+ )
254
+ return []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json-source-edit
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Surgical, byte-preserving JSON editing — change only what you intend to change.
5
5
  Author: Jérémie Lumbroso
6
6
  License: MIT
@@ -62,14 +62,12 @@ everything else on the reel undisturbed.
62
62
 
63
63
  ## Install
64
64
 
65
- Not yet on PyPI (see [Status](#status) below). Until then:
66
-
67
65
  ```bash
68
- git clone https://github.com/jlumbroso/json-source-edit
69
- cd json-source-edit
70
- pip install -e .
66
+ pip install json-source-edit
71
67
  ```
72
68
 
69
+ (Or clone the repo and `pip install -e .` for local development.)
70
+
73
71
  ## Usage
74
72
 
75
73
  ```python
@@ -121,12 +119,13 @@ it: `get_value`, `get_modifications`, and `preview_diff`.
121
119
  |---|---|
122
120
  | `replace` | Supported — any path, any depth |
123
121
  | `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` |
122
+ | `test` | Supported — not a text edit, an assertion. Evaluated against the document as it stood before this batch's own operations (not a naive sequential reading of RFC 6902 — see the docstring on `operations.Test`), with type-strict comparison (`1` does not test-equal `1.0` or `true`) |
123
+ | `add`, `move`, `copy` | Not implemented — raise `NotImplementedError`. Planned next (see `CHANGELOG.md`) |
125
124
 
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.
125
+ Multiple `replace`/`remove`/`test` calls batch correctly against the same
126
+ original document — every path resolves in original-document coordinates
127
+ regardless of what else is in the batch, so edit order doesn't matter and
128
+ one deletion can't corrupt another edit's position.
130
129
 
131
130
  ## Benchmark (v0)
132
131
 
@@ -174,10 +173,10 @@ make a package like this one feasible to write at all.
174
173
 
175
174
  ## Status
176
175
 
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`).
176
+ Published: [pypi.org/project/json-source-edit](https://pypi.org/project/json-source-edit/).
177
+ `pip install json-source-edit`. 203 tests, 100% line coverage. See
178
+ `CHANGELOG.md` for release history and the Scope table above for what's
179
+ implemented.
181
180
 
182
181
  ## License
183
182
 
@@ -197,14 +197,107 @@ def test_ops_2_6_5_remove_from_a_scalar_parent_still_unsupported():
197
197
  assert "/scalar" in message
198
198
 
199
199
 
200
- # --- Future operations remain stubbed ---
200
+ # --- Future operations remain stubbed (Mission 5: Test is implemented below; Add/Move/Copy are not) ---
201
201
 
202
202
  @pytest.mark.parametrize("op", [
203
203
  Add("/x", 1),
204
204
  Move("/ignored", "/a", "/b"),
205
205
  Copy("/ignored", "/a", "/b"),
206
- Test("/x", 1),
207
206
  ])
208
207
  def test_future_operations_not_implemented(op):
209
208
  with pytest.raises(NotImplementedError):
210
209
  op.compile(sm.generate('{"a": 1}'), {"a": 1}, '{"a": 1}')
210
+
211
+
212
+ # --- Mission 5: Test operation (TEST-2.* — not a text edit, a value assertion) ---
213
+
214
+ from json_source_edit.operations import TestOperationFailed # noqa: E402
215
+
216
+
217
+ def test_test_2_1_passing_test_contributes_no_edits():
218
+ text = '{"a": 1, "steps": [1, 2, 3]}'
219
+ assert _compile(Test("/a", 1), text) == []
220
+ assert _apply(Test("/a", 1), text) == text
221
+
222
+
223
+ @pytest.mark.parametrize("path,expected,json_text", [
224
+ ("/a", 1, '{"a": 1}'),
225
+ ("/name", "hello", '{"name": "hello"}'),
226
+ ("/flag", True, '{"flag": true}'),
227
+ ("/nothing", None, '{"nothing": null}'),
228
+ ("/items/1", 20, '{"items": [10, 20, 30]}'),
229
+ ("/nested/deep", "x", '{"nested": {"deep": "x"}}'),
230
+ ("", {"a": 1}, '{"a": 1}'), # root path
231
+ ])
232
+ def test_test_2_2_passes_for_matching_values_various_types_and_positions(path, expected, json_text):
233
+ assert _compile(Test(path, expected), json_text) == []
234
+
235
+
236
+ def test_test_2_3_failing_test_raises_with_actionable_message():
237
+ with pytest.raises(TestOperationFailed) as exc_info:
238
+ _compile(Test("/a", 2), '{"a": 1}')
239
+ message = str(exc_info.value)
240
+ assert "/a" in message and "2" in message and "1" in message
241
+
242
+
243
+ def test_test_2_4_missing_path_fails_cleanly_not_a_raw_lookup_error():
244
+ """RFC 6902: testing a path that doesn't exist is a *failed* test, not
245
+ a malformed-patch error -- resolve_path's KeyError/IndexError/ValueError
246
+ must not leak past Test.compile()."""
247
+ with pytest.raises(TestOperationFailed):
248
+ _compile(Test("/nonexistent", 1), '{"a": 1}')
249
+ with pytest.raises(TestOperationFailed):
250
+ _compile(Test("/items/99", 1), '{"items": [1, 2]}')
251
+
252
+
253
+ @pytest.mark.parametrize("path,expected,json_text", [
254
+ ("/flag", 1, '{"flag": true}'), # bool stored, int expected
255
+ ("/count", True, '{"count": 1}'), # int stored, bool expected
256
+ ("/n", 1.0, '{"n": 1}'), # int stored, float expected
257
+ ("/n", 1, '{"n": 1.0}'), # float stored, int expected
258
+ ])
259
+ def test_test_2_5_type_strict_comparison_rejects_bool_int_float_crossover(path, expected, json_text):
260
+ """Plain Python `==` would pass all four of these (bool is an int
261
+ subclass; 1 == 1.0) -- a package whose whole point is preserving exact
262
+ original representation shouldn't blur that in its comparison op."""
263
+ with pytest.raises(TestOperationFailed):
264
+ _compile(Test(path, expected), json_text)
265
+
266
+
267
+ def test_test_2_6_failing_test_aborts_the_whole_batch_nothing_partial_applies():
268
+ """A replace queued *before* a failing test must not land -- compile()
269
+ raises before apply_edits() ever runs, for every op in the batch."""
270
+ from json_source_edit.editor import JSONEditor
271
+
272
+ editor = JSONEditor.from_string('{"a": 1}')
273
+ editor.replace("/a", 999)
274
+ editor.test("/a", 2) # fails against the pristine value, 1
275
+ with pytest.raises(TestOperationFailed):
276
+ editor.apply()
277
+
278
+
279
+ def test_test_2_7_evaluates_against_pristine_batch_start_not_sequential_effect():
280
+ """Deliberate deviation from a naive sequential JSON Patch reading: a
281
+ `test` following a `replace` on the same path in the SAME batch sees
282
+ the pristine pre-batch value, not the queued replace's effect -- the
283
+ same coordinate-frame contract every other operation in this package
284
+ follows (Round 4 §1). A test against the value the batch is ABOUT to
285
+ set (999) would fail; against the ORIGINAL value (1) it passes."""
286
+ from json_source_edit.editor import JSONEditor
287
+
288
+ editor = JSONEditor.from_string('{"a": 1}')
289
+ editor.replace("/a", 999)
290
+ editor.test("/a", 1) # pristine value, not editor.replace's queued 999
291
+ result = editor.apply()
292
+ assert json.loads(result)["a"] == 999 # the replace still lands: test passed
293
+
294
+
295
+ def test_test_2_8_editor_test_records_no_modification():
296
+ """A passing test doesn't change the document -- get_modifications()'s
297
+ audit trail is specifically about what changed, so Test shouldn't
298
+ appear there."""
299
+ from json_source_edit.editor import JSONEditor
300
+
301
+ editor = JSONEditor.from_string('{"a": 1}')
302
+ editor.test("/a", 1)
303
+ assert editor.get_modifications() == []