json-atom 0.4.0__tar.gz → 0.4.1__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.1}/PKG-INFO +1 -1
- {json_atom-0.4.0 → json_atom-0.4.1}/pyproject.toml +1 -1
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/_identity.py +52 -18
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_diff.py +169 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/.github/workflows/ci.yml +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/.github/workflows/publish.yml +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/.gitignore +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/.pre-commit-config.yaml +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/CLAUDE.md +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/LICENSE +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/README.md +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/examples/advanced_identity.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/examples/audit_log.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/examples/data_sync.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/examples/index_vs_keyed.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/examples/keyed_arrays.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/examples/quick_api_payload.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/examples/state_transitions.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/examples/undo_redo.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/__init__.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/_utils.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/apply.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/compare.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/diff.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/errors.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/invert.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/json_patch.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/models.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/path.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/py.typed +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/src/json_atom/validate.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/__init__.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/conftest.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/fixtures/basic-replace.json +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/fixtures/keyed-array-update.json +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_apply.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_compare.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_conformance.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_cross_validation.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_edge_cases.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_extensions.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_invert.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_json_patch.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_models.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_move_copy.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_path.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_pydantic.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_utils.py +0 -0
- {json_atom-0.4.0 → json_atom-0.4.1}/tests/test_validate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: json-atom
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -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(
|
|
@@ -595,6 +595,175 @@ class TestCallableIdentityKeys:
|
|
|
595
595
|
)
|
|
596
596
|
|
|
597
597
|
|
|
598
|
+
class TestNestedPathIdentityKeys:
|
|
599
|
+
"""Nested dot-path identity keys (e.g. positionNumber.value → @.positionNumber.value)."""
|
|
600
|
+
|
|
601
|
+
def test_nested_path_string_key(self) -> None:
|
|
602
|
+
"""String key with dots resolves nested path."""
|
|
603
|
+
old = {"items": [
|
|
604
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
605
|
+
{"positionNumber": {"value": "002"}, "description": "beta"},
|
|
606
|
+
]}
|
|
607
|
+
new = {"items": [
|
|
608
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
609
|
+
]}
|
|
610
|
+
delta = diff_delta(old, new, array_identity_keys={"items": "positionNumber.value"})
|
|
611
|
+
remove_ops = [op for op in delta.operations if op.op == "remove"]
|
|
612
|
+
assert len(remove_ops) == 1
|
|
613
|
+
assert remove_ops[0].path == "$.items[?(@.positionNumber.value=='002')]"
|
|
614
|
+
|
|
615
|
+
def test_nested_path_resolver(self) -> None:
|
|
616
|
+
"""IdentityResolver with nested path produces dot notation."""
|
|
617
|
+
old = {"items": [
|
|
618
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
619
|
+
{"positionNumber": {"value": "002"}, "description": "beta"},
|
|
620
|
+
]}
|
|
621
|
+
new = {"items": [
|
|
622
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
623
|
+
{"positionNumber": {"value": "003"}, "description": "gamma"},
|
|
624
|
+
]}
|
|
625
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
626
|
+
"items": IdentityResolver(
|
|
627
|
+
"positionNumber.value",
|
|
628
|
+
lambda e: e["positionNumber"]["value"],
|
|
629
|
+
),
|
|
630
|
+
})
|
|
631
|
+
assert len(delta.operations) == 2
|
|
632
|
+
removes = [op for op in delta.operations if op.op == "remove"]
|
|
633
|
+
adds = [op for op in delta.operations if op.op == "add"]
|
|
634
|
+
assert len(removes) == 1
|
|
635
|
+
assert removes[0].path == "$.items[?(@.positionNumber.value=='002')]"
|
|
636
|
+
assert len(adds) == 1
|
|
637
|
+
assert adds[0].path == "$.items[?(@.positionNumber.value=='003')]"
|
|
638
|
+
|
|
639
|
+
def test_nested_path_update_within_element(self) -> None:
|
|
640
|
+
"""Update a property inside an element with nested identity path."""
|
|
641
|
+
old = {"items": [
|
|
642
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
643
|
+
{"positionNumber": {"value": "002"}, "description": "beta"},
|
|
644
|
+
]}
|
|
645
|
+
new = {"items": [
|
|
646
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
647
|
+
{"positionNumber": {"value": "002"}, "description": "updated"},
|
|
648
|
+
]}
|
|
649
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
650
|
+
"items": "positionNumber.value",
|
|
651
|
+
})
|
|
652
|
+
assert len(delta.operations) == 1
|
|
653
|
+
assert delta.operations[0].op == "replace"
|
|
654
|
+
assert delta.operations[0].path == "$.items[?(@.positionNumber.value=='002')].description"
|
|
655
|
+
assert delta.operations[0]["value"] == "updated"
|
|
656
|
+
|
|
657
|
+
def test_nested_path_roundtrip(self) -> None:
|
|
658
|
+
"""Nested path identity produces deltas that round-trip."""
|
|
659
|
+
old = {"items": [
|
|
660
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
661
|
+
{"positionNumber": {"value": "002"}, "description": "beta"},
|
|
662
|
+
]}
|
|
663
|
+
new = {"items": [
|
|
664
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
665
|
+
{"positionNumber": {"value": "003"}, "description": "gamma"},
|
|
666
|
+
]}
|
|
667
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
668
|
+
"items": "positionNumber.value",
|
|
669
|
+
})
|
|
670
|
+
result = apply_delta(deep_clone(old), delta)
|
|
671
|
+
assert result == new
|
|
672
|
+
|
|
673
|
+
def test_nested_path_invert_roundtrip(self) -> None:
|
|
674
|
+
"""Invert + apply round-trip with nested identity paths."""
|
|
675
|
+
old = {"items": [
|
|
676
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
677
|
+
{"positionNumber": {"value": "002"}, "description": "beta"},
|
|
678
|
+
]}
|
|
679
|
+
new = {"items": [
|
|
680
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
681
|
+
{"positionNumber": {"value": "002"}, "description": "updated"},
|
|
682
|
+
]}
|
|
683
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
684
|
+
"items": "positionNumber.value",
|
|
685
|
+
})
|
|
686
|
+
applied = apply_delta(deep_clone(old), delta)
|
|
687
|
+
assert applied == new
|
|
688
|
+
inverted = invert_delta(delta)
|
|
689
|
+
reverted = apply_delta(deep_clone(applied), inverted)
|
|
690
|
+
assert reverted == old
|
|
691
|
+
|
|
692
|
+
def test_nested_path_3_levels(self) -> None:
|
|
693
|
+
"""3-level nested identity path (a.b.c)."""
|
|
694
|
+
old = {"items": [
|
|
695
|
+
{"meta": {"org": {"id": "X"}}, "val": 10},
|
|
696
|
+
{"meta": {"org": {"id": "Y"}}, "val": 20},
|
|
697
|
+
]}
|
|
698
|
+
new = {"items": [
|
|
699
|
+
{"meta": {"org": {"id": "X"}}, "val": 10},
|
|
700
|
+
{"meta": {"org": {"id": "Y"}}, "val": 30},
|
|
701
|
+
]}
|
|
702
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
703
|
+
"items": IdentityResolver("meta.org.id", lambda e: e["meta"]["org"]["id"]),
|
|
704
|
+
})
|
|
705
|
+
assert len(delta.operations) == 1
|
|
706
|
+
assert delta.operations[0].path == "$.items[?(@.meta.org.id=='Y')].val"
|
|
707
|
+
result = apply_delta(deep_clone(old), delta)
|
|
708
|
+
assert result == new
|
|
709
|
+
reverted = apply_delta(deep_clone(result), invert_delta(delta))
|
|
710
|
+
assert reverted == old
|
|
711
|
+
|
|
712
|
+
def test_nested_path_numeric_value(self) -> None:
|
|
713
|
+
"""Nested identity path with numeric value."""
|
|
714
|
+
old = {"items": [
|
|
715
|
+
{"code": {"num": 1}, "name": "Widget"},
|
|
716
|
+
{"code": {"num": 2}, "name": "Gadget"},
|
|
717
|
+
]}
|
|
718
|
+
new = {"items": [
|
|
719
|
+
{"code": {"num": 1}, "name": "Widget"},
|
|
720
|
+
{"code": {"num": 2}, "name": "Updated"},
|
|
721
|
+
]}
|
|
722
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
723
|
+
"items": IdentityResolver("code.num", lambda e: e["code"]["num"]),
|
|
724
|
+
})
|
|
725
|
+
assert len(delta.operations) == 1
|
|
726
|
+
assert delta.operations[0].path == "$.items[?(@.code.num==2)].name"
|
|
727
|
+
result = apply_delta(deep_clone(old), delta)
|
|
728
|
+
assert result == new
|
|
729
|
+
|
|
730
|
+
def test_nested_path_boolean_value(self) -> None:
|
|
731
|
+
"""Nested identity path with boolean value."""
|
|
732
|
+
old = {"items": [
|
|
733
|
+
{"config": {"active": True}, "label": "on"},
|
|
734
|
+
{"config": {"active": False}, "label": "off"},
|
|
735
|
+
]}
|
|
736
|
+
new = {"items": [
|
|
737
|
+
{"config": {"active": True}, "label": "on"},
|
|
738
|
+
{"config": {"active": False}, "label": "disabled"},
|
|
739
|
+
]}
|
|
740
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
741
|
+
"items": IdentityResolver("config.active", lambda e: e["config"]["active"]),
|
|
742
|
+
})
|
|
743
|
+
assert len(delta.operations) == 1
|
|
744
|
+
assert delta.operations[0].path == "$.items[?(@.config.active==false)].label"
|
|
745
|
+
result = apply_delta(deep_clone(old), delta)
|
|
746
|
+
assert result == new
|
|
747
|
+
|
|
748
|
+
def test_nested_path_null_value(self) -> None:
|
|
749
|
+
"""Nested identity path with null value."""
|
|
750
|
+
old = {"items": [
|
|
751
|
+
{"status": {"code": None}, "label": "pending"},
|
|
752
|
+
{"status": {"code": "OK"}, "label": "done"},
|
|
753
|
+
]}
|
|
754
|
+
new = {"items": [
|
|
755
|
+
{"status": {"code": None}, "label": "waiting"},
|
|
756
|
+
{"status": {"code": "OK"}, "label": "done"},
|
|
757
|
+
]}
|
|
758
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
759
|
+
"items": IdentityResolver("status.code", lambda e: e["status"]["code"]),
|
|
760
|
+
})
|
|
761
|
+
assert len(delta.operations) == 1
|
|
762
|
+
assert delta.operations[0].path == "$.items[?(@.status.code==null)].label"
|
|
763
|
+
result = apply_delta(deep_clone(old), delta)
|
|
764
|
+
assert result == new
|
|
765
|
+
|
|
766
|
+
|
|
598
767
|
# ---------------------------------------------------------------------------
|
|
599
768
|
# Regex-based array key routing
|
|
600
769
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|