json-atom 0.4.0__tar.gz → 0.4.2__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 (66) hide show
  1. {json_atom-0.4.0 → json_atom-0.4.2}/CLAUDE.md +2 -2
  2. {json_atom-0.4.0 → json_atom-0.4.2}/PKG-INFO +39 -6
  3. {json_atom-0.4.0 → json_atom-0.4.2}/README.md +37 -4
  4. {json_atom-0.4.0 → json_atom-0.4.2}/pyproject.toml +1 -1
  5. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/_identity.py +52 -18
  6. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/_utils.py +4 -0
  7. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/apply.py +17 -23
  8. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/diff.py +8 -0
  9. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/models.py +9 -0
  10. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/path.py +39 -31
  11. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/validate.py +20 -8
  12. json_atom-0.4.2/tests/fixtures/README.md +6 -0
  13. json_atom-0.4.2/tests/fixtures/copy-property.json +31 -0
  14. json_atom-0.4.2/tests/fixtures/escaped-property-and-filter.json +33 -0
  15. json_atom-0.4.2/tests/fixtures/index-array-operations.json +28 -0
  16. json_atom-0.4.2/tests/fixtures/mixed-member-filter.json +37 -0
  17. json_atom-0.4.2/tests/fixtures/mixed-operations.json +64 -0
  18. json_atom-0.4.2/tests/fixtures/move-keyed-array-element.json +37 -0
  19. json_atom-0.4.2/tests/fixtures/move-rename-property.json +28 -0
  20. json_atom-0.4.2/tests/fixtures/nested-boolean-number.json +39 -0
  21. json_atom-0.4.2/tests/fixtures/nested-replace.json +33 -0
  22. json_atom-0.4.2/tests/fixtures/property-add-remove.json +29 -0
  23. json_atom-0.4.2/tests/fixtures/root-operations.json +20 -0
  24. json_atom-0.4.2/tests/fixtures/type-change-replace.json +23 -0
  25. json_atom-0.4.2/tests/fixtures/typed-key-filters.json +55 -0
  26. json_atom-0.4.2/tests/fixtures/value-filter-array.json +27 -0
  27. json_atom-0.4.2/tests/test_conformance.py +22 -0
  28. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_diff.py +169 -0
  29. json_atom-0.4.2/tests/test_interop_regressions.py +72 -0
  30. json_atom-0.4.0/tests/test_conformance.py +0 -70
  31. {json_atom-0.4.0 → json_atom-0.4.2}/.github/workflows/ci.yml +0 -0
  32. {json_atom-0.4.0 → json_atom-0.4.2}/.github/workflows/publish.yml +0 -0
  33. {json_atom-0.4.0 → json_atom-0.4.2}/.gitignore +0 -0
  34. {json_atom-0.4.0 → json_atom-0.4.2}/.pre-commit-config.yaml +0 -0
  35. {json_atom-0.4.0 → json_atom-0.4.2}/LICENSE +0 -0
  36. {json_atom-0.4.0 → json_atom-0.4.2}/examples/advanced_identity.py +0 -0
  37. {json_atom-0.4.0 → json_atom-0.4.2}/examples/audit_log.py +0 -0
  38. {json_atom-0.4.0 → json_atom-0.4.2}/examples/data_sync.py +0 -0
  39. {json_atom-0.4.0 → json_atom-0.4.2}/examples/index_vs_keyed.py +0 -0
  40. {json_atom-0.4.0 → json_atom-0.4.2}/examples/keyed_arrays.py +0 -0
  41. {json_atom-0.4.0 → json_atom-0.4.2}/examples/quick_api_payload.py +0 -0
  42. {json_atom-0.4.0 → json_atom-0.4.2}/examples/state_transitions.py +0 -0
  43. {json_atom-0.4.0 → json_atom-0.4.2}/examples/undo_redo.py +0 -0
  44. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/__init__.py +0 -0
  45. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/compare.py +0 -0
  46. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/errors.py +0 -0
  47. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/invert.py +0 -0
  48. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/json_patch.py +0 -0
  49. {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/py.typed +0 -0
  50. {json_atom-0.4.0 → json_atom-0.4.2}/tests/__init__.py +0 -0
  51. {json_atom-0.4.0 → json_atom-0.4.2}/tests/conftest.py +0 -0
  52. {json_atom-0.4.0 → json_atom-0.4.2}/tests/fixtures/basic-replace.json +0 -0
  53. {json_atom-0.4.0 → json_atom-0.4.2}/tests/fixtures/keyed-array-update.json +0 -0
  54. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_apply.py +0 -0
  55. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_compare.py +0 -0
  56. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_cross_validation.py +0 -0
  57. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_edge_cases.py +0 -0
  58. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_extensions.py +0 -0
  59. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_invert.py +0 -0
  60. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_json_patch.py +0 -0
  61. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_models.py +0 -0
  62. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_move_copy.py +0 -0
  63. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_path.py +0 -0
  64. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_pydantic.py +0 -0
  65. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_utils.py +0 -0
  66. {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_validate.py +0 -0
@@ -66,8 +66,8 @@ tests/
66
66
  - **Specification**: [JSON Atom v0](https://github.com/ltwlf/json-atom-format/blob/main/spec/v0.md)
67
67
  - **Conformance**: Level 2 (Apply + Reversible) — passes all fixtures
68
68
  - **Spec is source of truth** — when spec and TypeScript reference diverge, follow the spec
69
- - **Spec-faithful divergence from TS**: digit-after-dot (`$.items.0`) is a PathError per spec grammar
70
- - **remove without oldValue**: valid per spec (OPTIONAL), unlike TS which requires it
69
+ - **Shared v5 behavior**: both implementations reject digit-after-dot paths and accept remove operations without `oldValue` (undo still requires it).
70
+ - **Packaging**: PyPI distribution `json-atom`, repository `json-atom-py`, import `json_atom`.
71
71
 
72
72
  ## Coding Conventions
73
73
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: json-atom
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Python implementation of the JSON Atom v0 specification
5
5
  Project-URL: Homepage, https://github.com/ltwlf/json-atom-py
6
6
  Project-URL: Repository, https://github.com/ltwlf/json-atom-py
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
22
22
  # json-atom-py
23
23
 
24
24
  [![CI](https://github.com/ltwlf/json-atom-py/actions/workflows/ci.yml/badge.svg)](https://github.com/ltwlf/json-atom-py/actions/workflows/ci.yml)
25
- [![PyPI version](https://badge.fury.io/py/json-atom-py.svg)](https://pypi.org/project/json-atom-py/)
25
+ [![PyPI version](https://badge.fury.io/py/json-atom.svg)](https://pypi.org/project/json-atom/)
26
26
  [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
27
27
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
28
28
 
@@ -30,7 +30,9 @@ Description-Content-Type: text/markdown
30
30
 
31
31
  Zero dependencies. Fully typed. Python 3.12+.
32
32
 
33
- > **Ecosystem note:** This project implements the [JSON Atom specification](https://github.com/ltwlf/json-atom-format). It is unrelated to the older `json-atom` package on PyPI.
33
+ Created by [Christian Glessner](https://www.linkedin.com/in/christian-glessner/).
34
+
35
+ > **Package names:** The repository is `json-atom-py`, the PyPI distribution is [`json-atom`](https://pypi.org/project/json-atom/), and Python imports use `json_atom`. This project implements the [JSON Atom specification](https://github.com/ltwlf/json-atom-format).
34
36
 
35
37
  ```text
36
38
  json-atom-format (specification)
@@ -40,10 +42,22 @@ json-atom-format (specification)
40
42
 
41
43
  The specification defines the wire format. Each language implementation produces and consumes compatible deltas. A TypeScript implementation is also available: [json-diff-ts](https://github.com/ltwlf/json-diff-ts).
42
44
 
45
+ ## Release 0.4.2
46
+
47
+ This release aligns edge cases with the TypeScript implementation and the shared JSON Atom fixtures:
48
+
49
+ - Mixed dot/bracket identity paths preserve literal property names.
50
+ - Nested boolean/number changes are detected using JSON type equality.
51
+ - Root creation/deletion uses `add`/`remove` with `null` as the empty document.
52
+ - Failed moves preserve their source, and equivalent bracket paths cannot bypass move restrictions.
53
+ - Unsupported wire versions are rejected.
54
+
55
+ Validated with 824 Python tests, all 16 shared conformance fixtures, and 205 document pairs exchanged in both directions with TypeScript, including undo.
56
+
43
57
  ## Installation
44
58
 
45
59
  ```bash
46
- pip install json-atom-py
60
+ pip install json-atom
47
61
  ```
48
62
 
49
63
  ## Quick Start
@@ -79,6 +93,25 @@ The delta is a `Delta` instance (a `dict` subclass) — JSON-serializable, stora
79
93
  }
80
94
  ```
81
95
 
96
+ ## Sending atoms between Python and TypeScript
97
+
98
+ The wire envelope is always `{"format": "json-atom", "version": 1, "operations": [...]}`.
99
+ The draft specification filename (`spec/v0.md`) and package versions are independent of this wire version.
100
+
101
+ Serialize a plain dictionary when writing JSON, including empty deltas:
102
+
103
+ ```python
104
+ payload = json.dumps(dict(delta))
105
+ # For files or streams: json.dump(dict(delta), output_file)
106
+ ```
107
+
108
+ `Delta` deliberately uses operation-count truthiness. Converting to `dict` preserves the envelope when
109
+ Python's streaming or indented JSON encoder encounters a delta with no operations.
110
+
111
+ TypeScript can consume that payload with `applyAtom(source, JSON.parse(payload))` from
112
+ [`json-diff-ts`](https://github.com/ltwlf/json-diff-ts). Use string identity keys for integers outside
113
+ JavaScript's safe integer range. Diff options are implementation-specific; the emitted format is shared.
114
+
82
115
  ## Typed Models
83
116
 
84
117
  Delta and Operation are dict subclasses with full IDE support — autocomplete, typed properties, factory methods, and extension attribute access:
@@ -165,7 +198,7 @@ Add /items/0 ← now it's "NewItem"
165
198
  Update /items/1 ← this used to be /items/0
166
199
  ```
167
200
 
168
- This makes diffs fragile. You can't store them, replay them reliably, or build audit logs on top of them. This is the fundamental problem with index-based formats like JSON Patch (RFC 6902): paths like `/items/0` are positional, so any insertion, deletion, or reorder invalidates every subsequent path.
201
+ Positional patches apply correctly to their expected source state. Independently reordering the target array can change which item a stored index addresses. Identity-based paths help when items must be addressed by key; they do not provide conflict resolution.
169
202
 
170
203
  **JSON Atom solves this with key-based identity.** Array elements are matched by a stable key, and paths use JSONPath filter expressions that survive insertions, deletions, and reordering:
171
204
 
@@ -1,7 +1,7 @@
1
1
  # json-atom-py
2
2
 
3
3
  [![CI](https://github.com/ltwlf/json-atom-py/actions/workflows/ci.yml/badge.svg)](https://github.com/ltwlf/json-atom-py/actions/workflows/ci.yml)
4
- [![PyPI version](https://badge.fury.io/py/json-atom-py.svg)](https://pypi.org/project/json-atom-py/)
4
+ [![PyPI version](https://badge.fury.io/py/json-atom.svg)](https://pypi.org/project/json-atom/)
5
5
  [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
@@ -9,7 +9,9 @@
9
9
 
10
10
  Zero dependencies. Fully typed. Python 3.12+.
11
11
 
12
- > **Ecosystem note:** This project implements the [JSON Atom specification](https://github.com/ltwlf/json-atom-format). It is unrelated to the older `json-atom` package on PyPI.
12
+ Created by [Christian Glessner](https://www.linkedin.com/in/christian-glessner/).
13
+
14
+ > **Package names:** The repository is `json-atom-py`, the PyPI distribution is [`json-atom`](https://pypi.org/project/json-atom/), and Python imports use `json_atom`. This project implements the [JSON Atom specification](https://github.com/ltwlf/json-atom-format).
13
15
 
14
16
  ```text
15
17
  json-atom-format (specification)
@@ -19,10 +21,22 @@ json-atom-format (specification)
19
21
 
20
22
  The specification defines the wire format. Each language implementation produces and consumes compatible deltas. A TypeScript implementation is also available: [json-diff-ts](https://github.com/ltwlf/json-diff-ts).
21
23
 
24
+ ## Release 0.4.2
25
+
26
+ This release aligns edge cases with the TypeScript implementation and the shared JSON Atom fixtures:
27
+
28
+ - Mixed dot/bracket identity paths preserve literal property names.
29
+ - Nested boolean/number changes are detected using JSON type equality.
30
+ - Root creation/deletion uses `add`/`remove` with `null` as the empty document.
31
+ - Failed moves preserve their source, and equivalent bracket paths cannot bypass move restrictions.
32
+ - Unsupported wire versions are rejected.
33
+
34
+ Validated with 824 Python tests, all 16 shared conformance fixtures, and 205 document pairs exchanged in both directions with TypeScript, including undo.
35
+
22
36
  ## Installation
23
37
 
24
38
  ```bash
25
- pip install json-atom-py
39
+ pip install json-atom
26
40
  ```
27
41
 
28
42
  ## Quick Start
@@ -58,6 +72,25 @@ The delta is a `Delta` instance (a `dict` subclass) — JSON-serializable, stora
58
72
  }
59
73
  ```
60
74
 
75
+ ## Sending atoms between Python and TypeScript
76
+
77
+ The wire envelope is always `{"format": "json-atom", "version": 1, "operations": [...]}`.
78
+ The draft specification filename (`spec/v0.md`) and package versions are independent of this wire version.
79
+
80
+ Serialize a plain dictionary when writing JSON, including empty deltas:
81
+
82
+ ```python
83
+ payload = json.dumps(dict(delta))
84
+ # For files or streams: json.dump(dict(delta), output_file)
85
+ ```
86
+
87
+ `Delta` deliberately uses operation-count truthiness. Converting to `dict` preserves the envelope when
88
+ Python's streaming or indented JSON encoder encounters a delta with no operations.
89
+
90
+ TypeScript can consume that payload with `applyAtom(source, JSON.parse(payload))` from
91
+ [`json-diff-ts`](https://github.com/ltwlf/json-diff-ts). Use string identity keys for integers outside
92
+ JavaScript's safe integer range. Diff options are implementation-specific; the emitted format is shared.
93
+
61
94
  ## Typed Models
62
95
 
63
96
  Delta and Operation are dict subclasses with full IDE support — autocomplete, typed properties, factory methods, and extension attribute access:
@@ -144,7 +177,7 @@ Add /items/0 ← now it's "NewItem"
144
177
  Update /items/1 ← this used to be /items/0
145
178
  ```
146
179
 
147
- This makes diffs fragile. You can't store them, replay them reliably, or build audit logs on top of them. This is the fundamental problem with index-based formats like JSON Patch (RFC 6902): paths like `/items/0` are positional, so any insertion, deletion, or reorder invalidates every subsequent path.
180
+ Positional patches apply correctly to their expected source state. Independently reordering the target array can change which item a stored index addresses. Identity-based paths help when items must be addressed by key; they do not provide conflict resolution.
148
181
 
149
182
  **JSON Atom solves this with key-based identity.** Array elements are matched by a stable key, and paths use JSONPath filter expressions that survive insertions, deletions, and reordering:
150
183
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "json-atom"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "Python implementation of the JSON Atom v0 specification"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -14,6 +14,7 @@ from typing import Any, Literal
14
14
 
15
15
  from json_atom._utils import json_equal
16
16
  from json_atom.errors import DiffError
17
+ from json_atom.path import _NESTED_PATH_RE
17
18
 
18
19
 
19
20
  @dataclass(frozen=True, slots=True)
@@ -23,20 +24,28 @@ class IdentityResolver:
23
24
  The ``property`` name appears in filter paths: ``[?(@.{property}==...)]``.
24
25
  The ``resolve`` callable extracts the identity value from an element.
25
26
 
27
+ Supports nested dot paths like ``positionNumber.value`` which resolves
28
+ via ``elem["positionNumber"]["value"]``. When a custom resolver is
29
+ provided, the stored value is validated using the same dot-path
30
+ semantics for ``property``.
31
+
26
32
  .. important::
27
33
 
28
34
  The value returned by ``resolve`` **must** match the value actually
29
- stored under ``elem[property]`` for that element. JSON Atom applies
30
- keyed-array filters by testing ``elem[property] == literal``, so if
31
- the resolver returns a synthetic or composite value that is not
32
- literally stored on the element, the generated delta paths will not
33
- match during ``apply_delta`` / ``resolve_path``.
35
+ stored at the ``property`` path on the element. JSON Atom applies
36
+ keyed-array filters by resolving the property path, so if the
37
+ resolver returns a value that doesn't match the stored value, the
38
+ generated delta paths will not match during ``apply_delta``.
34
39
 
35
40
  Example::
36
41
 
37
42
  # Simple key: use the ``id`` field directly
38
43
  IdentityResolver("id", lambda e: e["id"])
39
44
 
45
+ # Nested key: use a dot-separated path
46
+ IdentityResolver("positionNumber.value",
47
+ lambda e: e["positionNumber"]["value"])
48
+
40
49
  # Validation: coerce to int, ensuring consistency
41
50
  # (only safe when the stored value round-trips through int)
42
51
  IdentityResolver("id", lambda e: int(e["id"]))
@@ -125,6 +134,28 @@ def _normalize_identity_value(value: IdentityKey) -> _ResolvedIdentity:
125
134
  )
126
135
 
127
136
 
137
+ _SENTINEL = object()
138
+
139
+
140
+ def _resolve_nested(elem: Any, key_property: str) -> Any:
141
+ """Resolve a possibly-nested dot path on a dict element.
142
+
143
+ Returns ``_SENTINEL`` if any segment is missing.
144
+ For non-nested keys, falls back to a simple dict lookup.
145
+ """
146
+ if not isinstance(elem, dict):
147
+ return _SENTINEL
148
+ is_nested = "." in key_property and _NESTED_PATH_RE.match(key_property) is not None
149
+ if not is_nested:
150
+ return elem.get(key_property, _SENTINEL)
151
+ current: Any = elem
152
+ for seg in key_property.split("."):
153
+ if not isinstance(current, dict) or seg not in current:
154
+ return _SENTINEL
155
+ current = current[seg]
156
+ return current
157
+
158
+
128
159
  def extract_identity(
129
160
  elem: Any,
130
161
  key_property: str,
@@ -133,8 +164,11 @@ def extract_identity(
133
164
  """Extract the identity value from an array element.
134
165
 
135
166
  Uses the custom resolver if provided, otherwise reads the key property
136
- directly from the element dict. The returned value must be a JSON scalar
137
- (str, int, float, bool, or None) suitable for use in filter paths.
167
+ directly from the element dict. Supports nested dot paths like
168
+ ``positionNumber.value`` → ``elem["positionNumber"]["value"]``.
169
+
170
+ The returned value must be a JSON scalar (str, int, float, bool, or None)
171
+ suitable for use in filter paths.
138
172
 
139
173
  Raises:
140
174
  DiffError: If the element is missing the key property or the resolver
@@ -147,25 +181,25 @@ def extract_identity(
147
181
  raise DiffError(
148
182
  f"Identity resolver for '{key_property}' failed on element {elem!r}: {exc}"
149
183
  ) from exc
150
- # Verify the element is a dict with the key property and that the
151
- # stored value matches the resolved value — apply_delta matches
152
- # filter paths via elem[key_property] == literal, so a mismatch
153
- # would produce unapplyable deltas.
154
- if not isinstance(elem, dict) or key_property not in elem:
184
+ # Verify the element has the key property and that the stored value
185
+ # matches the resolved value — apply_delta matches filter paths via
186
+ # the key property, so a mismatch would produce unapplyable deltas.
187
+ stored = _resolve_nested(elem, key_property)
188
+ if stored is _SENTINEL:
155
189
  raise DiffError(
156
190
  f"Resolver for '{key_property}' returned {value!r} but element "
157
- f"is not a dict with '{key_property}': {elem!r}"
191
+ f"is missing identity path '{key_property}': {elem!r}"
158
192
  )
159
- stored = elem[key_property]
160
193
  if not json_equal(stored, value):
161
194
  raise DiffError(
162
195
  f"Resolver for '{key_property}' returned {value!r} but "
163
- f"elem['{key_property}'] is {stored!r} — filter path would not match"
196
+ f"stored value at '{key_property}' is {stored!r} — filter path would not match"
164
197
  )
165
- elif not isinstance(elem, dict) or key_property not in elem:
166
- raise DiffError(f"Array element missing identity key '{key_property}': {elem!r}")
167
198
  else:
168
- value = elem[key_property]
199
+ stored = _resolve_nested(elem, key_property)
200
+ if stored is _SENTINEL:
201
+ raise DiffError(f"Array element missing identity key '{key_property}': {elem!r}")
202
+ value = stored
169
203
 
170
204
  if not isinstance(value, (str, int, float, bool, type(None))):
171
205
  raise DiffError(
@@ -47,6 +47,10 @@ def json_equal(a: Any, b: Any) -> bool:
47
47
  if type(a) is not type(b):
48
48
  return False
49
49
 
50
+ if isinstance(a, dict):
51
+ return a.keys() == b.keys() and all(json_equal(value, b[key]) for key, value in a.items())
52
+ if isinstance(a, list):
53
+ return len(a) == len(b) and all(json_equal(left, right) for left, right in zip(a, b, strict=True))
50
54
  return a == b # type: ignore[no-any-return]
51
55
 
52
56
 
@@ -122,6 +122,11 @@ def _apply_move(obj: Any, op: Operation) -> Any:
122
122
  # Read value at source
123
123
  value = _read_value_at_path(obj, from_path)
124
124
 
125
+ # Preflight on a clone: a failed destination must not consume the source.
126
+ preview = copy.deepcopy(obj)
127
+ preview = _apply_operation(preview, Operation(op="remove", path=from_path))
128
+ _apply_operation(preview, Operation(op="add", path=to_path, value=value))
129
+
125
130
  # Remove from source
126
131
  remove_op = Operation(op="remove", path=from_path)
127
132
  obj = _apply_operation(obj, remove_op)
@@ -232,19 +237,19 @@ def _resolve_parent(
232
237
  _SENTINEL = object()
233
238
 
234
239
 
235
- def _resolve_property(obj: Any, key: str, *, literal: bool = False) -> Any:
240
+ def _resolve_property(obj: Any, key: str, *, literal: bool = False, members: tuple[str, ...] | None = None) -> Any:
236
241
  """Resolve a property on a dict.
237
242
 
238
243
  When ``literal`` is False and the key contains dots, traverses nested segments.
239
244
  When ``literal`` is True, treats the key as a literal property name.
240
245
  Returns ``_SENTINEL`` if the property is missing (distinguishes from ``None``).
241
246
  """
242
- if literal or "." not in key:
247
+ if members is None and (literal or "." not in key):
243
248
  if isinstance(obj, dict) and key in obj:
244
249
  return obj[key]
245
250
  return _SENTINEL
246
251
  current = obj
247
- for seg in key.split("."):
252
+ for seg in members if members is not None else key.split("."):
248
253
  if not isinstance(current, dict) or seg not in current:
249
254
  return _SENTINEL
250
255
  current = current[seg]
@@ -260,7 +265,7 @@ def _find_key_filter_match(arr: list[Any], seg: KeyFilterSegment, path_str: str)
260
265
  """
261
266
  matches: list[int] = []
262
267
  for idx, elem in enumerate(arr):
263
- resolved = _resolve_property(elem, seg.property, literal=seg.literal_key)
268
+ resolved = _resolve_property(elem, seg.property, literal=seg.literal_key, members=seg.member_path)
264
269
  if resolved is not _SENTINEL and json_equal(resolved, seg.value):
265
270
  matches.append(idx)
266
271
 
@@ -293,9 +298,7 @@ def _find_value_filter_match(arr: list[Any], seg: ValueFilterSegment, path_str:
293
298
  # ---------------------------------------------------------------------------
294
299
 
295
300
 
296
- def _apply_property_op(
297
- parent: Any, name: str, op_type: str, op: Operation, path_str: str
298
- ) -> None:
301
+ def _apply_property_op(parent: Any, name: str, op_type: str, op: Operation, path_str: str) -> None:
299
302
  """Apply an operation on an object property."""
300
303
  if not isinstance(parent, dict):
301
304
  raise ApplyError(f"Cannot apply property operation on {_type_name(parent)}: {path_str}")
@@ -324,9 +327,7 @@ def _apply_property_op(
324
327
  # ---------------------------------------------------------------------------
325
328
 
326
329
 
327
- def _apply_index_op(
328
- parent: Any, index: int, op_type: str, op: Operation, path_str: str
329
- ) -> None:
330
+ def _apply_index_op(parent: Any, index: int, op_type: str, op: Operation, path_str: str) -> None:
330
331
  """Apply an operation on an array index."""
331
332
  if not isinstance(parent, list):
332
333
  raise ApplyError(f"Cannot apply index operation on {_type_name(parent)}: {path_str}")
@@ -371,7 +372,7 @@ def _apply_key_filter_op(
371
372
  if op_type == "add":
372
373
  # Filter must NOT match any existing element
373
374
  for elem in parent:
374
- resolved = _resolve_property(elem, seg.property, literal=seg.literal_key)
375
+ resolved = _resolve_property(elem, seg.property, literal=seg.literal_key, members=seg.member_path)
375
376
  if resolved is not _SENTINEL and json_equal(resolved, seg.value):
376
377
  raise ApplyError(f"Key filter already matches an element (use 'replace'): {path_str}")
377
378
 
@@ -440,27 +441,20 @@ def _apply_value_filter_op(
440
441
  # ---------------------------------------------------------------------------
441
442
 
442
443
 
443
- def _validate_keyed_array_consistency(
444
- value: Any, seg: KeyFilterSegment, path_str: str
445
- ) -> None:
444
+ def _validate_keyed_array_consistency(value: Any, seg: KeyFilterSegment, path_str: str) -> None:
446
445
  """Validate that the value contains the identity property matching the filter.
447
446
 
448
447
  Applies to 'add' and element-level 'replace' on key-filtered paths
449
448
  (no trailing segments after filter).
450
449
  """
451
450
  if not isinstance(value, dict):
452
- raise ApplyError(
453
- f"Keyed-array value must be an object, got {_type_name(value)}: {path_str}"
454
- )
455
- resolved = _resolve_property(value, seg.property, literal=seg.literal_key)
451
+ raise ApplyError(f"Keyed-array value must be an object, got {_type_name(value)}: {path_str}")
452
+ resolved = _resolve_property(value, seg.property, literal=seg.literal_key, members=seg.member_path)
456
453
  if resolved is _SENTINEL:
457
- raise ApplyError(
458
- f"Keyed-array value missing identity property '{seg.property}': {path_str}"
459
- )
454
+ raise ApplyError(f"Keyed-array value missing identity property '{seg.property}': {path_str}")
460
455
  if not json_equal(resolved, seg.value):
461
456
  raise ApplyError(
462
- f"Keyed-array value identity mismatch: "
463
- f"filter expects {seg.value!r} but value has {resolved!r}: {path_str}"
457
+ f"Keyed-array value identity mismatch: filter expects {seg.value!r} but value has {resolved!r}: {path_str}"
464
458
  )
465
459
 
466
460
 
@@ -107,6 +107,14 @@ def _diff_values(
107
107
  if json_equal(old, new):
108
108
  return
109
109
 
110
+ # At document root, null represents absence (spec Section 6.5).
111
+ if not segments and old is None:
112
+ _emit_add(new, segments, operations)
113
+ return
114
+ if not segments and new is None:
115
+ _emit_remove(old, segments, reversible, operations)
116
+ return
117
+
110
118
  old_is_dict = isinstance(old, dict) and not isinstance(old, bool)
111
119
  new_is_dict = isinstance(new, dict) and not isinstance(new, bool)
112
120
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import builtins
5
6
  import re
6
7
  from collections.abc import Callable, Iterator
7
8
  from dataclasses import dataclass
@@ -53,6 +54,14 @@ class KeyFilterSegment:
53
54
  property: str
54
55
  value: Any
55
56
  literal_key: bool = False
57
+ member_path: tuple[str, ...] | None = None
58
+
59
+ @builtins.property
60
+ def members(self) -> tuple[str, ...]:
61
+ """Unambiguous property names, including mixed bracket/dot access."""
62
+ if self.member_path is not None:
63
+ return self.member_path
64
+ return (self.property,) if self.literal_key else tuple(self.property.split("."))
56
65
 
57
66
 
58
67
  @dataclass(frozen=True, slots=True)
@@ -153,26 +153,32 @@ def _parse_filter(inner: str) -> KeyFilterSegment | ValueFilterSegment:
153
153
 
154
154
  `inner` is the text between '@' and ')' (exclusive).
155
155
  """
156
- if inner.startswith("."):
157
- # Key filter with dot property: .key==val or .a.b==val (nested per RFC 9535)
158
- eq_pos = inner.find("==")
159
- if eq_pos == -1:
160
- raise PathError(f"Invalid filter expression: missing '==' in @{inner}")
161
- key = inner[1:eq_pos]
162
- if not key or not _NESTED_PATH_RE.match(key):
163
- raise PathError(f"Invalid property name in filter: {key!r}")
164
- literal_str = inner[eq_pos + 2 :]
165
- return KeyFilterSegment(property=key, value=parse_filter_literal(literal_str))
166
-
167
- if inner.startswith("['"):
168
- # Key filter with bracket property: ['dotted.key']==val
169
- key, close_quote_idx = _extract_quoted_string(inner, 2) # after ['
170
- # After the closing quote, expect ']==' then the literal
171
- rest = inner[close_quote_idx + 1 :] # from ']' onwards
172
- if not rest.startswith("]=="):
173
- raise PathError("Invalid bracket filter syntax: expected ']==' after quoted key")
174
- literal_str = rest[3:] # skip ']==
175
- return KeyFilterSegment(property=key, value=parse_filter_literal(literal_str), literal_key=True)
156
+ if inner.startswith((".", "['")):
157
+ members: list[str] = []
158
+ i = 0
159
+ while i < len(inner) and not inner.startswith("==", i):
160
+ if inner[i] == ".":
161
+ match = re.match(r"[a-zA-Z_][a-zA-Z0-9_]*", inner[i + 1 :])
162
+ if match is None:
163
+ raise PathError("Invalid property name in filter")
164
+ members.append(match.group())
165
+ i += 1 + len(match.group())
166
+ elif inner.startswith("['", i):
167
+ key, close = _extract_quoted_string(inner, i + 2)
168
+ if inner[close + 1 : close + 2] != "]":
169
+ raise PathError("Expected ']' after filter property")
170
+ members.append(key)
171
+ i = close + 2
172
+ else:
173
+ raise PathError("Invalid member access in filter")
174
+ if not inner.startswith("==", i):
175
+ raise PathError("Invalid filter expression: missing '=='")
176
+ value = parse_filter_literal(inner[i + 2 :])
177
+ if len(members) == 1:
178
+ return KeyFilterSegment(property=members[0], value=value, literal_key=inner.startswith("['"))
179
+ if all(_PROPERTY_NAME_RE.fullmatch(member) for member in members):
180
+ return KeyFilterSegment(property=".".join(members), value=value)
181
+ return KeyFilterSegment(property=".".join(members), value=value, member_path=tuple(members))
176
182
 
177
183
  if inner.startswith("=="):
178
184
  # Value filter: ==val
@@ -397,19 +403,17 @@ def _resolve_key_filter(arr: Any, seg: KeyFilterSegment) -> int:
397
403
 
398
404
  _sentinel = object()
399
405
 
400
- def _resolve(obj: Any, key: str) -> Any:
401
- if seg.literal_key or "." not in key:
402
- return obj.get(key, _sentinel) if isinstance(obj, dict) else _sentinel
406
+ def _resolve(obj: Any) -> Any:
403
407
  cur = obj
404
- for s in key.split("."):
405
- if not isinstance(cur, dict) or s not in cur:
408
+ for member in seg.members:
409
+ if not isinstance(cur, dict) or member not in cur:
406
410
  return _sentinel
407
- cur = cur[s]
411
+ cur = cur[member]
408
412
  return cur
409
413
 
410
414
  matches: list[int] = []
411
415
  for idx, elem in enumerate(arr):
412
- resolved = _resolve(elem, seg.property)
416
+ resolved = _resolve(elem)
413
417
  if resolved is not _sentinel and json_equal(resolved, seg.value):
414
418
  matches.append(idx)
415
419
 
@@ -420,9 +424,7 @@ def _resolve_key_filter(arr: Any, seg: KeyFilterSegment) -> int:
420
424
  if len(matches) > 1:
421
425
  literal = format_filter_literal(seg.value)
422
426
  member = f".{seg.property}" if _NESTED_PATH_RE.match(seg.property) else f"['{seg.property}']"
423
- raise PathError(
424
- f"Key filter [?(@{member}=={literal})] matched {len(matches)} elements (must be exactly one)"
425
- )
427
+ raise PathError(f"Key filter [?(@{member}=={literal})] matched {len(matches)} elements (must be exactly one)")
426
428
  return matches[0]
427
429
 
428
430
 
@@ -474,7 +476,13 @@ def build_path(
474
476
  elif isinstance(seg, KeyFilterSegment):
475
477
  prop = seg.property
476
478
  literal = format_filter_literal(seg.value)
477
- if seg.literal_key and not _PROPERTY_NAME_RE.match(prop):
479
+ if seg.member_path is not None:
480
+ member = "".join(
481
+ f".{key}" if _PROPERTY_NAME_RE.fullmatch(key) else "['" + key.replace("'", "''") + "']"
482
+ for key in seg.members
483
+ )
484
+ parts.append(f"[?(@{member}=={literal})]")
485
+ elif seg.literal_key and not _PROPERTY_NAME_RE.match(prop):
478
486
  # Literal property name that can't use dot notation — bracket
479
487
  escaped_prop = prop.replace("'", "''")
480
488
  parts.append(f"[?(@['{escaped_prop}']=={literal})]")
@@ -7,7 +7,9 @@ from __future__ import annotations
7
7
 
8
8
  from typing import Any
9
9
 
10
+ from json_atom.errors import PathError
10
11
  from json_atom.models import ValidationResult
12
+ from json_atom.path import build_path, parse_path
11
13
 
12
14
  # Fields defined by the spec for the delta envelope
13
15
  _ENVELOPE_REQUIRED = {"format", "version", "operations"}
@@ -44,6 +46,9 @@ def validate_delta(delta: Any) -> ValidationResult:
44
46
  elif not isinstance(delta["version"], int) or isinstance(delta["version"], bool):
45
47
  errors.append(f"Invalid version: expected integer, got {type(delta['version']).__name__}")
46
48
 
49
+ elif delta["version"] != 1:
50
+ errors.append(f"Unsupported version: {delta['version']!r}; expected 1")
51
+
47
52
  # Required field: operations
48
53
  if "operations" not in delta:
49
54
  errors.append("Missing required field: 'operations'")
@@ -68,7 +73,7 @@ def _validate_operation(op: Any, index: int, errors: list[str]) -> None:
68
73
  # Required: op
69
74
  if "op" not in op:
70
75
  errors.append(f"{prefix}: missing required field 'op'")
71
- elif op["op"] not in _VALID_OPS:
76
+ elif not isinstance(op["op"], str) or op["op"] not in _VALID_OPS:
72
77
  errors.append(f"{prefix}: invalid op {op['op']!r}, must be one of: {', '.join(sorted(_VALID_OPS))}")
73
78
 
74
79
  # Required: path
@@ -109,7 +114,7 @@ def _validate_operation(op: Any, index: int, errors: list[str]) -> None:
109
114
  from_path = op.get("from")
110
115
  to_path = op.get("path")
111
116
  if isinstance(from_path, str) and isinstance(to_path, str):
112
- if from_path == to_path:
117
+ if _same_path(from_path, to_path):
113
118
  errors.append(f"{prefix}: 'move' 'from' must differ from 'path'")
114
119
  elif _is_path_prefix(from_path, to_path):
115
120
  errors.append(f"{prefix}: 'move' cannot move a value into its own subtree")
@@ -124,11 +129,18 @@ def _validate_operation(op: Any, index: int, errors: list[str]) -> None:
124
129
  # Note: value is OPTIONAL on copy (for reversibility)
125
130
 
126
131
 
127
- def _is_path_prefix(from_path: str, target_path: str) -> bool:
128
- """Check if target_path is a descendant of from_path.
132
+ def _same_path(left: str, right: str) -> bool:
133
+ try:
134
+ return build_path(parse_path(left)) == build_path(parse_path(right))
135
+ except (PathError, ValueError):
136
+ return left == right
129
137
 
130
- Root path ('$') is excluded — moving from root to a descendant is valid.
131
- """
132
- if from_path == "$":
138
+
139
+ def _is_path_prefix(from_path: str, target_path: str) -> bool:
140
+ """Compare canonical paths so bracket aliases and root obey subtree rules."""
141
+ try:
142
+ source = build_path(parse_path(from_path))
143
+ target = build_path(parse_path(target_path))
144
+ except (PathError, ValueError):
133
145
  return False
134
- return target_path.startswith(from_path + ".") or target_path.startswith(from_path + "[")
146
+ return target.startswith(source + ".") or target.startswith(source + "[")
@@ -0,0 +1,6 @@
1
+ # Shared JSON Atom conformance fixtures
2
+
3
+ Mirrored from [json-atom-format/fixtures](https://github.com/ltwlf/json-atom-format/tree/main/fixtures).
4
+ The specification repository is the source of truth. Keep the JSON files identical across implementations.
5
+
6
+ Every fixture validates application; level 2 also validates inversion and round-trip recovery.