Make kanta.diff/patch functions public API.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
from .callbacks import DictPrev, DictState, LogFmt
|
from .callbacks import DictPrev, DictState, LogFmt
|
||||||
|
from .diff import diff, patch
|
||||||
from .exceptions import DatabaseError
|
from .exceptions import DatabaseError
|
||||||
from .kanta import Kanta
|
from .kanta import Kanta
|
||||||
from .logging import LogEvent, configure_logging
|
from .logging import LogEvent, configure_logging
|
||||||
@@ -8,6 +9,8 @@ __all__ = [
|
|||||||
"Kanta",
|
"Kanta",
|
||||||
"DatabaseError",
|
"DatabaseError",
|
||||||
"configure_logging",
|
"configure_logging",
|
||||||
|
"diff",
|
||||||
|
"patch",
|
||||||
# Callback argument types
|
# Callback argument types
|
||||||
"DictPrev",
|
"DictPrev",
|
||||||
"DictState",
|
"DictState",
|
||||||
|
|||||||
+4
-4
@@ -49,16 +49,16 @@ def _diff(previous, current):
|
|||||||
return current
|
return current
|
||||||
|
|
||||||
|
|
||||||
def compute_diff(previous: dict, current: dict) -> dict | None:
|
def diff(previous: dict, current: dict) -> dict | None:
|
||||||
"""Compute a marshaled diff between two state dicts.
|
"""Compute a marshaled diff between two state dicts.
|
||||||
|
|
||||||
Returns None if there is no difference.
|
Returns None if there is no difference.
|
||||||
"""
|
"""
|
||||||
diff = _diff(previous, current)
|
result = _diff(previous, current)
|
||||||
return diff if diff is not _UNCHANGED else None
|
return result if result is not _UNCHANGED else None
|
||||||
|
|
||||||
|
|
||||||
def patch_state(state: dict, diff: dict) -> dict:
|
def patch(state: dict, diff: dict) -> dict:
|
||||||
"""Apply a marshaled diff to a state dict."""
|
"""Apply a marshaled diff to a state dict."""
|
||||||
return apply_diff(state, diff)
|
return apply_diff(state, diff)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -13,7 +13,7 @@ from dataclasses import dataclass
|
|||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from kanta.diff import compute_diff
|
from kanta.diff import diff
|
||||||
from kanta.exceptions import DatabaseError
|
from kanta.exceptions import DatabaseError
|
||||||
|
|
||||||
# Cache registries by imported module object so that many Kanta instances using
|
# Cache registries by imported module object so that many Kanta instances using
|
||||||
@@ -179,7 +179,7 @@ class Migrations:
|
|||||||
self._call_migration(fn, data_dict, kanta)
|
self._call_migration(fn, data_dict, kanta)
|
||||||
current_version = version
|
current_version = version
|
||||||
changed = before != data_dict
|
changed = before != data_dict
|
||||||
diff = compute_diff(before, data_dict) if changed else None
|
delta = diff(before, data_dict) if changed else None
|
||||||
desc = (fn.__doc__ or f"v{version}").split("\n")[0].rstrip(".")
|
desc = (fn.__doc__ or f"v{version}").split("\n")[0].rstrip(".")
|
||||||
migrations.append(
|
migrations.append(
|
||||||
MigrationInfo(
|
MigrationInfo(
|
||||||
@@ -187,7 +187,7 @@ class Migrations:
|
|||||||
description=desc,
|
description=desc,
|
||||||
version=version,
|
version=version,
|
||||||
changed=changed,
|
changed=changed,
|
||||||
diff=diff,
|
diff=delta,
|
||||||
before=before,
|
before=before,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from kanta.callbacks import CallbackRegistry, InjectionContext
|
from kanta.callbacks import CallbackRegistry, InjectionContext
|
||||||
from kanta.diff import compute_diff
|
from kanta.diff import diff
|
||||||
from kanta.exceptions import DatabaseError, DataIntegrityError
|
from kanta.exceptions import DatabaseError, DataIntegrityError
|
||||||
from kanta.filelock import LockedFile
|
from kanta.filelock import LockedFile
|
||||||
from kanta.structs import ChangeRecord
|
from kanta.structs import ChangeRecord
|
||||||
@@ -158,11 +158,11 @@ class PersistenceMixin:
|
|||||||
The queued :class:`ChangeRecord`, or ``None`` if the diff was empty
|
The queued :class:`ChangeRecord`, or ``None`` if the diff was empty
|
||||||
and *force* is ``False``.
|
and *force* is ``False``.
|
||||||
"""
|
"""
|
||||||
diff = compute_diff(self.statedict, current)
|
delta = diff(self.statedict, current)
|
||||||
if not diff:
|
if not delta:
|
||||||
if not force:
|
if not force:
|
||||||
return None
|
return None
|
||||||
diff = {}
|
delta = {}
|
||||||
|
|
||||||
# The clock is only read when a record is actually queued.
|
# The clock is only read when a record is actually queued.
|
||||||
now = self.now()
|
now = self.now()
|
||||||
@@ -182,7 +182,7 @@ class PersistenceMixin:
|
|||||||
v=self.version,
|
v=self.version,
|
||||||
u=user,
|
u=user,
|
||||||
m=m,
|
m=m,
|
||||||
diff=diff,
|
diff=delta,
|
||||||
)
|
)
|
||||||
self.pending_changes.append(record)
|
self.pending_changes.append(record)
|
||||||
self.statedict = copy.deepcopy(current)
|
self.statedict = copy.deepcopy(current)
|
||||||
|
|||||||
+3
-3
@@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any, Union
|
|||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
from kanta.callbacks import InjectionContext
|
from kanta.callbacks import InjectionContext
|
||||||
from kanta.diff import patch_state
|
from kanta.diff import patch
|
||||||
from kanta.exceptions import ReplayError
|
from kanta.exceptions import ReplayError
|
||||||
from kanta.logging import _USER_PATH, LogEvent, transaction_logger
|
from kanta.logging import _USER_PATH, LogEvent, transaction_logger
|
||||||
from kanta.structs import ChangeRecord, Snapshot
|
from kanta.structs import ChangeRecord, Snapshot
|
||||||
@@ -116,7 +116,7 @@ def scan_events(content: bytes, kanta: Kanta[Any]) -> tuple[list[Event], int]:
|
|||||||
events.append(SnapshotEvent(line_number, byte_pos, record_index, snap))
|
events.append(SnapshotEvent(line_number, byte_pos, record_index, snap))
|
||||||
else:
|
else:
|
||||||
record = impl.serializer.decode(payload, type=ChangeRecord)
|
record = impl.serializer.decode(payload, type=ChangeRecord)
|
||||||
state = patch_state(state, record.diff)
|
state = patch(state, record.diff)
|
||||||
events.append(ChangeEvent(line_number, byte_pos, record_index, record))
|
events.append(ChangeEvent(line_number, byte_pos, record_index, record))
|
||||||
change_count += 1
|
change_count += 1
|
||||||
except msgspec.DecodeError as exc:
|
except msgspec.DecodeError as exc:
|
||||||
@@ -146,7 +146,7 @@ def replay_events(
|
|||||||
yield event, None, state
|
yield event, None, state
|
||||||
else:
|
else:
|
||||||
previous = copy.deepcopy(state)
|
previous = copy.deepcopy(state)
|
||||||
state = patch_state(state, event.record.diff)
|
state = patch(state, event.record.diff)
|
||||||
yield event, previous, state
|
yield event, previous, state
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from contextlib import contextmanager
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from kanta.diff import compute_diff
|
from kanta.diff import diff
|
||||||
from kanta.exceptions import DataIntegrityError
|
from kanta.exceptions import DataIntegrityError
|
||||||
from kanta.callbacks import InjectionContext
|
from kanta.callbacks import InjectionContext
|
||||||
from kanta.logging import _USER_PATH, LogEvent, emit_event, transaction_logger
|
from kanta.logging import _USER_PATH, LogEvent, emit_event, transaction_logger
|
||||||
@@ -65,19 +65,19 @@ def transaction(
|
|||||||
if current_dict != impl.statedict:
|
if current_dict != impl.statedict:
|
||||||
is_bootstrap = action in {"bootstrap"}
|
is_bootstrap = action in {"bootstrap"}
|
||||||
if not (is_bootstrap and not impl.statedict):
|
if not (is_bootstrap and not impl.statedict):
|
||||||
diff = compute_diff(impl.statedict, current_dict)
|
delta = diff(impl.statedict, current_dict)
|
||||||
if diff:
|
if delta:
|
||||||
_logger.critical(
|
_logger.critical(
|
||||||
"Database state modified outside of transaction! "
|
"Database state modified outside of transaction! "
|
||||||
"This indicates a bug where changes occurred without a transaction wrapper.\n"
|
"This indicates a bug where changes occurred without a transaction wrapper.\n"
|
||||||
"Changes detected: %s",
|
"Changes detected: %s",
|
||||||
diff,
|
delta,
|
||||||
)
|
)
|
||||||
raise DataIntegrityError(
|
raise DataIntegrityError(
|
||||||
"Database state modified outside of transaction",
|
"Database state modified outside of transaction",
|
||||||
db_path=impl.db_path,
|
db_path=impl.db_path,
|
||||||
action=action,
|
action=action,
|
||||||
diff=diff,
|
diff=delta,
|
||||||
)
|
)
|
||||||
|
|
||||||
impl.in_transaction = True
|
impl.in_transaction = True
|
||||||
@@ -86,8 +86,8 @@ def transaction(
|
|||||||
try:
|
try:
|
||||||
yield impl.data
|
yield impl.data
|
||||||
new_dict = struct_to_dict(impl.data, serializer=impl.serializer)
|
new_dict = struct_to_dict(impl.data, serializer=impl.serializer)
|
||||||
diff = compute_diff(impl.statedict, new_dict)
|
delta = diff(impl.statedict, new_dict)
|
||||||
if diff:
|
if delta:
|
||||||
if impl.callback_registry.has("validate"):
|
if impl.callback_registry.has("validate"):
|
||||||
impl.callback_registry.invoke_sync(
|
impl.callback_registry.invoke_sync(
|
||||||
"validate",
|
"validate",
|
||||||
|
|||||||
+40
-40
@@ -3,7 +3,7 @@
|
|||||||
jsondiff is a dev dependency used only here, to verify that:
|
jsondiff is a dev dependency used only here, to verify that:
|
||||||
|
|
||||||
- jsondiff.patch(..., marshal=True) can apply patches produced by
|
- jsondiff.patch(..., marshal=True) can apply patches produced by
|
||||||
compute_diff (our format is a subset of jsondiff's marshaled syntax);
|
diff (our format is a subset of jsondiff's marshaled syntax);
|
||||||
- apply_diff can apply patches produced by jsondiff.diff(..., marshal=True),
|
- apply_diff can apply patches produced by jsondiff.diff(..., marshal=True),
|
||||||
including positional $insert/$delete list edits and per-index nested diffs.
|
including positional $insert/$delete list edits and per-index nested diffs.
|
||||||
"""
|
"""
|
||||||
@@ -11,88 +11,88 @@ jsondiff is a dev dependency used only here, to verify that:
|
|||||||
import jsondiff
|
import jsondiff
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from kanta.diff import compute_diff, patch_state
|
from kanta.diff import diff, patch
|
||||||
from kanta.logging import format_diff
|
from kanta.logging import format_diff
|
||||||
from kanta.serialization.base import apply_diff
|
from kanta.serialization.base import apply_diff
|
||||||
|
|
||||||
# --- Producer: compute_diff ------------------------------------------------
|
# --- Producer: diff ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_no_diff():
|
def test_no_diff():
|
||||||
assert compute_diff({"a": 1}, {"a": 1}) is None
|
assert diff({"a": 1}, {"a": 1}) is None
|
||||||
assert compute_diff({}, {}) is None
|
assert diff({}, {}) is None
|
||||||
|
|
||||||
|
|
||||||
def test_simple_diff():
|
def test_simple_diff():
|
||||||
diff = compute_diff({"a": 1}, {"a": 2})
|
delta = diff({"a": 1}, {"a": 2})
|
||||||
assert diff is not None
|
assert delta is not None
|
||||||
assert diff == {"a": 2}
|
assert delta == {"a": 2}
|
||||||
|
|
||||||
|
|
||||||
def test_nested_diff():
|
def test_nested_diff():
|
||||||
diff = compute_diff({"x": {"y": 1}}, {"x": {"y": 2}})
|
delta = diff({"x": {"y": 1}}, {"x": {"y": 2}})
|
||||||
assert diff == {"x": {"y": 2}}
|
assert delta == {"x": {"y": 2}}
|
||||||
|
|
||||||
|
|
||||||
def test_key_added():
|
def test_key_added():
|
||||||
assert compute_diff({"a": 1}, {"a": 1, "b": 2}) == {"b": 2}
|
assert diff({"a": 1}, {"a": 1, "b": 2}) == {"b": 2}
|
||||||
|
|
||||||
|
|
||||||
def test_key_removed():
|
def test_key_removed():
|
||||||
assert compute_diff({"a": 1, "b": 2}, {"a": 1}) == {"$delete": ["b"]}
|
assert diff({"a": 1, "b": 2}, {"a": 1}) == {"$delete": ["b"]}
|
||||||
|
|
||||||
|
|
||||||
def test_last_key_removed_is_delete_not_replace():
|
def test_last_key_removed_is_delete_not_replace():
|
||||||
# jsondiff's minimal-diff search emits {"$replace": {}} here; we emit
|
# jsondiff's minimal-diff search emits {"$replace": {}} here; we emit
|
||||||
# what actually happened: the key was deleted.
|
# what actually happened: the key was deleted.
|
||||||
assert compute_diff({"a": 1}, {}) == {"$delete": ["a"]}
|
assert diff({"a": 1}, {}) == {"$delete": ["a"]}
|
||||||
assert compute_diff({"x": {"y": 1}}, {"x": {}}) == {"x": {"$delete": ["y"]}}
|
assert diff({"x": {"y": 1}}, {"x": {}}) == {"x": {"$delete": ["y"]}}
|
||||||
|
|
||||||
|
|
||||||
def test_list_changes_are_full_assignment():
|
def test_list_changes_are_full_assignment():
|
||||||
# No $insert/$delete positional edits: lists are replaced wholesale.
|
# No $insert/$delete positional edits: lists are replaced wholesale.
|
||||||
assert compute_diff({"l": [1, 2]}, {"l": [1, 2, 3]}) == {"l": [1, 2, 3]}
|
assert diff({"l": [1, 2]}, {"l": [1, 2, 3]}) == {"l": [1, 2, 3]}
|
||||||
assert compute_diff({"l": [1, 2, 3]}, {"l": [1, 3]}) == {"l": [1, 3]}
|
assert diff({"l": [1, 2, 3]}, {"l": [1, 3]}) == {"l": [1, 3]}
|
||||||
assert compute_diff({"l": [1]}, {"l": []}) == {"l": []}
|
assert diff({"l": [1]}, {"l": []}) == {"l": []}
|
||||||
|
|
||||||
|
|
||||||
def test_list_with_unchanged_prefix_is_full_assignment():
|
def test_list_with_unchanged_prefix_is_full_assignment():
|
||||||
diff = compute_diff({"l": ["a", "b", "c"]}, {"l": ["a", "x", "b", "c"]})
|
delta = diff({"l": ["a", "b", "c"]}, {"l": ["a", "x", "b", "c"]})
|
||||||
assert diff == {"l": ["a", "x", "b", "c"]}
|
assert delta == {"l": ["a", "x", "b", "c"]}
|
||||||
|
|
||||||
|
|
||||||
def test_type_changes_are_full_assignment():
|
def test_type_changes_are_full_assignment():
|
||||||
assert compute_diff({"a": {"x": 1}}, {"a": [1]}) == {"a": [1]}
|
assert diff({"a": {"x": 1}}, {"a": [1]}) == {"a": [1]}
|
||||||
# A dict replacing a non-dict is a plain assignment too: the consumer
|
# A dict replacing a non-dict is a plain assignment too: the consumer
|
||||||
# sees from the old value whether to patch (dict) or replace.
|
# sees from the old value whether to patch (dict) or replace.
|
||||||
assert compute_diff({"a": [1]}, {"a": {"x": 1}}) == {"a": {"x": 1}}
|
assert diff({"a": [1]}, {"a": {"x": 1}}) == {"a": {"x": 1}}
|
||||||
assert compute_diff({"a": 1}, {"a": None}) == {"a": None}
|
assert diff({"a": 1}, {"a": None}) == {"a": None}
|
||||||
|
|
||||||
|
|
||||||
def test_new_dict_value_assigned_wholesale():
|
def test_new_dict_value_assigned_wholesale():
|
||||||
assert compute_diff({}, {"a": {"x": 1}}) == {"a": {"x": 1}}
|
assert diff({}, {"a": {"x": 1}}) == {"a": {"x": 1}}
|
||||||
|
|
||||||
|
|
||||||
def test_dollar_keys_escaped():
|
def test_dollar_keys_escaped():
|
||||||
assert compute_diff({}, {"$weird": 1}) == {"$$weird": 1}
|
assert diff({}, {"$weird": 1}) == {"$$weird": 1}
|
||||||
assert compute_diff({"$weird": 1}, {"$weird": 2}) == {"$$weird": 2}
|
assert diff({"$weird": 1}, {"$weird": 2}) == {"$$weird": 2}
|
||||||
assert compute_diff({"$weird": 1}, {}) == {"$delete": ["$$weird"]}
|
assert diff({"$weird": 1}, {}) == {"$delete": ["$$weird"]}
|
||||||
|
|
||||||
|
|
||||||
def test_dollar_values_not_escaped():
|
def test_dollar_values_not_escaped():
|
||||||
# Only keys are escaped; values are stored verbatim, even "$delete".
|
# Only keys are escaped; values are stored verbatim, even "$delete".
|
||||||
assert compute_diff({"s": 1}, {"s": "$y"}) == {"s": "$y"}
|
assert diff({"s": 1}, {"s": "$y"}) == {"s": "$y"}
|
||||||
assert compute_diff({"s": 1}, {"s": "$delete"}) == {"s": "$delete"}
|
assert diff({"s": 1}, {"s": "$delete"}) == {"s": "$delete"}
|
||||||
assert compute_diff({}, {"o": {"s": "$y", "l": ["$z"]}}) == {
|
assert diff({}, {"o": {"s": "$y", "l": ["$z"]}}) == {
|
||||||
"o": {"s": "$y", "l": ["$z"]}
|
"o": {"s": "$y", "l": ["$z"]}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# --- Consumer: apply_diff / patch_state -------------------------------------
|
# --- Consumer: apply_diff / patch -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_patch_state_delegates():
|
def test_patch_delegates():
|
||||||
assert patch_state({"a": 1}, {"a": 2}) == {"a": 2}
|
assert patch({"a": 1}, {"a": 2}) == {"a": 2}
|
||||||
|
|
||||||
|
|
||||||
def test_apply_scalar_and_add():
|
def test_apply_scalar_and_add():
|
||||||
@@ -218,9 +218,9 @@ JSONDIFF_APPLIES_CASES = [
|
|||||||
"name,old,new", JSONDIFF_APPLIES_CASES, ids=[c[0] for c in JSONDIFF_APPLIES_CASES]
|
"name,old,new", JSONDIFF_APPLIES_CASES, ids=[c[0] for c in JSONDIFF_APPLIES_CASES]
|
||||||
)
|
)
|
||||||
def test_jsondiff_applies_our_patches(name, old, new):
|
def test_jsondiff_applies_our_patches(name, old, new):
|
||||||
diff = compute_diff(old, new)
|
delta = diff(old, new)
|
||||||
assert diff is not None
|
assert delta is not None
|
||||||
assert jsondiff.patch(old, diff, marshal=True) == new
|
assert jsondiff.patch(old, delta, marshal=True) == new
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name,old,new", COMPAT_CASES, ids=[c[0] for c in COMPAT_CASES])
|
@pytest.mark.parametrize("name,old,new", COMPAT_CASES, ids=[c[0] for c in COMPAT_CASES])
|
||||||
@@ -231,15 +231,15 @@ def test_we_apply_jsondiff_patches(name, old, new):
|
|||||||
|
|
||||||
@pytest.mark.parametrize("name,old,new", COMPAT_CASES, ids=[c[0] for c in COMPAT_CASES])
|
@pytest.mark.parametrize("name,old,new", COMPAT_CASES, ids=[c[0] for c in COMPAT_CASES])
|
||||||
def test_our_own_round_trip(name, old, new):
|
def test_our_own_round_trip(name, old, new):
|
||||||
diff = compute_diff(old, new)
|
delta = diff(old, new)
|
||||||
assert diff is not None
|
assert delta is not None
|
||||||
assert apply_diff(old, diff) == new
|
assert apply_diff(old, delta) == new
|
||||||
|
|
||||||
|
|
||||||
def test_no_diff_means_equal_states():
|
def test_no_diff_means_equal_states():
|
||||||
for _name, old, new in COMPAT_CASES:
|
for _name, old, new in COMPAT_CASES:
|
||||||
assert compute_diff(old, new) is not None # cases really differ
|
assert diff(old, new) is not None # cases really differ
|
||||||
assert compute_diff({"a": [1, {"b": "$x"}]}, {"a": [1, {"b": "$x"}]}) is None
|
assert diff({"a": [1, {"b": "$x"}]}, {"a": [1, {"b": "$x"}]}) is None
|
||||||
|
|
||||||
|
|
||||||
# --- Logging ----------------------------------------------------------------
|
# --- Logging ----------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user