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.
- {json_atom-0.4.0 → json_atom-0.4.2}/CLAUDE.md +2 -2
- {json_atom-0.4.0 → json_atom-0.4.2}/PKG-INFO +39 -6
- {json_atom-0.4.0 → json_atom-0.4.2}/README.md +37 -4
- {json_atom-0.4.0 → json_atom-0.4.2}/pyproject.toml +1 -1
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/_identity.py +52 -18
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/_utils.py +4 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/apply.py +17 -23
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/diff.py +8 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/models.py +9 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/path.py +39 -31
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/validate.py +20 -8
- json_atom-0.4.2/tests/fixtures/README.md +6 -0
- json_atom-0.4.2/tests/fixtures/copy-property.json +31 -0
- json_atom-0.4.2/tests/fixtures/escaped-property-and-filter.json +33 -0
- json_atom-0.4.2/tests/fixtures/index-array-operations.json +28 -0
- json_atom-0.4.2/tests/fixtures/mixed-member-filter.json +37 -0
- json_atom-0.4.2/tests/fixtures/mixed-operations.json +64 -0
- json_atom-0.4.2/tests/fixtures/move-keyed-array-element.json +37 -0
- json_atom-0.4.2/tests/fixtures/move-rename-property.json +28 -0
- json_atom-0.4.2/tests/fixtures/nested-boolean-number.json +39 -0
- json_atom-0.4.2/tests/fixtures/nested-replace.json +33 -0
- json_atom-0.4.2/tests/fixtures/property-add-remove.json +29 -0
- json_atom-0.4.2/tests/fixtures/root-operations.json +20 -0
- json_atom-0.4.2/tests/fixtures/type-change-replace.json +23 -0
- json_atom-0.4.2/tests/fixtures/typed-key-filters.json +55 -0
- json_atom-0.4.2/tests/fixtures/value-filter-array.json +27 -0
- json_atom-0.4.2/tests/test_conformance.py +22 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_diff.py +169 -0
- json_atom-0.4.2/tests/test_interop_regressions.py +72 -0
- json_atom-0.4.0/tests/test_conformance.py +0 -70
- {json_atom-0.4.0 → json_atom-0.4.2}/.github/workflows/ci.yml +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/.github/workflows/publish.yml +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/.gitignore +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/.pre-commit-config.yaml +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/LICENSE +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/examples/advanced_identity.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/examples/audit_log.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/examples/data_sync.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/examples/index_vs_keyed.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/examples/keyed_arrays.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/examples/quick_api_payload.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/examples/state_transitions.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/examples/undo_redo.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/__init__.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/compare.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/errors.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/invert.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/json_patch.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/src/json_atom/py.typed +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/__init__.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/conftest.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/fixtures/basic-replace.json +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/fixtures/keyed-array-update.json +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_apply.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_compare.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_cross_validation.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_edge_cases.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_extensions.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_invert.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_json_patch.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_models.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_move_copy.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_path.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_pydantic.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.2}/tests/test_utils.py +0 -0
- {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
|
-
- **
|
|
70
|
-
- **
|
|
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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: json-atom
|
|
3
|
-
Version: 0.4.
|
|
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
|
[](https://github.com/ltwlf/json-atom-py/actions/workflows/ci.yml)
|
|
25
|
-
[](https://pypi.org/project/json-atom/)
|
|
26
26
|
[](https://www.python.org/downloads/)
|
|
27
27
|
[](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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
[](https://github.com/ltwlf/json-atom-py/actions/workflows/ci.yml)
|
|
4
|
-
[](https://pypi.org/project/json-atom/)
|
|
5
5
|
[](https://www.python.org/downloads/)
|
|
6
6
|
[](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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
30
|
-
keyed-array filters by
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
137
|
-
|
|
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
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
|
|
154
|
-
if
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
|
405
|
-
if not isinstance(cur, dict) or
|
|
408
|
+
for member in seg.members:
|
|
409
|
+
if not isinstance(cur, dict) or member not in cur:
|
|
406
410
|
return _sentinel
|
|
407
|
-
cur = cur[
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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.
|