Make kanta.diff/patch functions public API.

This commit is contained in:
2026-09-13 00:29:02 +00:00
parent c608e749a7
commit 7a7716ec7b
7 changed files with 65 additions and 62 deletions
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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,
) )
) )
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 ----------------------------------------------------------------