Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ See source code for examples
* CI: https://github.com/stefankoegl/python-json-patch/actions
* Coveralls: https://coveralls.io/github/stefankoegl/python-json-patch

String append extension
-----------------------
`JsonPatchWithAppend` is a drop-in replacement for `JsonPatch` with a
non-RFC string `append` operation. Its `from_diff()` method sends only the
new suffix when an object member's string grows. `JsonPatch`, `make_patch()`
and `apply_patch()` retain their standard RFC 6902 behavior.

See the [tutorial](doc/tutorial.rst#string-append-extension) for examples.
Both sender and receiver must support the extension.

Type hints
----------
jsonpatch ships type stubs (in the `jsonpatch-stubs` directory, as
Expand Down
42 changes: 42 additions & 0 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ support for Python's ``Decimal`` type, can be used to create a custom
>>> patch.apply(doc)
{'foo': 1, 'bar': Decimal('1.10')}


Instead of subclassing it is also possible to pass a dump function to
``from_diff``:

Expand All @@ -391,3 +392,44 @@ and load function to ``from_string``:
>>> doc = {'foo': 1}
>>> patch.apply(doc)
{'foo': 1, 'bar': Decimal('1.10')}


String append extension
-----------------------

``JsonPatchWithAppend`` accepts the standard operations and an additional
``append`` operation for existing strings. This extension is not part of
RFC 6902, so both the producer and consumer of the patch must support it.
The standard ``JsonPatch`` class and module-level helpers remain unchanged.

.. code-block:: python

>>> src = {'message': 'Hello'}
>>> dst = {'message': 'Hello World'}
>>> patch = jsonpatch.JsonPatchWithAppend.from_diff(src, dst)
>>> patch.patch
[{'op': 'append', 'path': '/message', 'value': ' World'}]
>>> patch.apply(src)
{'message': 'Hello World'}
>>> jsonpatch.JsonPatchWithAppend.from_string(patch.to_string()).apply(src) == dst
True

Every append operation requires ``op``, ``path`` and a string ``value``.
Its target must be an existing string in an object member or array element;
appending to the root document is not supported. Operations can also be
constructed directly:

.. code-block:: python

>>> patch = jsonpatch.JsonPatchWithAppend([
... {'op': 'append', 'path': '/message', 'value': '!'},
... ])
>>> patch.apply({'message': 'Hello'})
{'message': 'Hello!'}

Automatic suffix detection applies to object members, including objects
nested in arrays. Root strings and scalar array elements retain the standard
diff behavior. ``optimization=False`` disables moves but retains suffix
detection. ``generate_test_ops=True`` also tests the original string before
appending. As with custom operations, ``in_place=True, atomic=True`` uses
the existing copy-based rollback fallback.
3 changes: 3 additions & 0 deletions jsonpatch-stubs/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ class MoveOperation(PatchOperation):

class TestOperation(PatchOperation): ...
class CopyOperation(PatchOperation): ...
class AppendOperation(PatchOperation): ...

class JsonPatch:
json_dumper: ClassVar[Callable[..., str]]
Expand Down Expand Up @@ -122,6 +123,8 @@ class JsonPatch:
def to_string(self, dumps: Callable[[Any], str] | None = None) -> str: ...
def apply(self, obj: Any, in_place: bool = False, atomic: bool = False) -> Any: ...

class JsonPatchWithAppend(JsonPatch): ...

# Computes the operations for JsonPatch.from_diff; mostly an implementation
# detail, so its internal methods are typed loosely.
class DiffBuilder:
Expand Down
76 changes: 76 additions & 0 deletions jsonpatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,39 @@ def _apply(self, obj, journal):
return obj


class AppendOperation(PatchOperation):
"""Appends a string suffix to an existing string (not part of RFC 6902)."""

def _apply(self, obj, journal):
try:
value = self.operation['value']
except KeyError:
raise InvalidJsonPatch("The operation does not contain a 'value' member", op=self)
if not isinstance(value, str):
raise JsonPatchConflict("Cannot append a non-string value", op=self)

try:
subobj, part = self.pointer.to_last(obj)
except JsonPointerException as ex:
raise JsonPatchPathNotFound(str(ex), op=self)

if part is None:
raise JsonPatchConflict("Cannot append to root document", op=self)
if isinstance(subobj, MutableSequence):
if not isinstance(part, int) or part < 0 or part >= len(subobj):
raise JsonPatchPathNotFound("Cannot append outside of list", op=self)
elif isinstance(subobj, MutableMapping):
if part not in subobj:
raise JsonPatchPathNotFound("Cannot append to a non-existent member", op=self)
else:
raise JsonPatchConflict("Cannot append to a non-string value", op=self)

if not isinstance(subobj[part], str):
raise JsonPatchConflict("Cannot append to a non-string value", op=self)
journal.setitem(subobj, part, subobj[part] + value)
return obj


def _restore(obj, saved):
"""Restores the contents of a document from a copy of it.

Expand Down Expand Up @@ -1004,6 +1037,35 @@ def _get_operation(self, operation):
return cls(operation, pointer_cls=self.pointer_cls)


class JsonPatchWithAppend(JsonPatch):
"""A JsonPatch with an optional, non-RFC string append operation.

Each append operation requires full ``op``, ``path`` and ``value`` members.
It appends a string suffix to an existing object member or array element.
The standard JsonPatch class and module-level helpers remain RFC 6902 only.
"""

operations = MappingProxyType(dict(JsonPatch.operations, append=AppendOperation))

@classmethod
def from_diff(cls, src, dst, optimization=True, dumps=None,
pointer_cls=JsonPointer, generate_test_ops=False):
"""Creates a patch, using append for string growth in object members.

Root strings and scalar array elements retain standard diff behavior.
Options follow JsonPatch.from_diff; optimization=False disables moves,
while append remains enabled. With generate_test_ops=True, an append
is preceded by a test of the original string.
"""
json_dumper = dumps or cls.json_dumper
builder = _AppendDiffBuilder(src, dst, json_dumper, pointer_cls=pointer_cls,
optimization=optimization,
generate_test_ops=generate_test_ops)
builder._compare_values((), None, src, dst)
ops = copy.deepcopy(list(builder.execute()))
return cls(ops, pointer_cls=pointer_cls)


class DiffBuilder(object):

def __init__(self, src_doc, dst_doc, dumps=json.dumps, pointer_cls=JsonPointer,
Expand Down Expand Up @@ -1357,6 +1419,20 @@ def _compare_values(self, path, key, src, dst):
self._item_replaced(path, key, dst, src)


class _AppendDiffBuilder(DiffBuilder):

def _item_replaced(self, path, key, item, old_item):
if (path or key is not None) and isinstance(old_item, str) and \
isinstance(item, str) and len(item) > len(old_item) and \
item.startswith(old_item):
location = self._location(_path_join(path, key))
self._insert_test(location, old_item)
self.insert({'op': 'append', 'path': location,
'value': item[len(old_item):]})
else:
super()._item_replaced(path, key, item, old_item)


def _positional_cost(old, new):
""" Estimates how many operations change old into new, if the items of
arrays are compared by position """
Expand Down
20 changes: 20 additions & 0 deletions property_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,26 @@ def patch_gives_same_result(self):
self.doc)


class AppendDiffProperties(unittest.TestCase):

@given(key=json_keys, prefix=st.text(max_size=10),
suffix=st.text(min_size=1, max_size=10), generate_tests=st.booleans())
def test_string_growth_uses_suffix(self, key, prefix, suffix, generate_tests):
src, dst = {key: prefix}, {key: prefix + suffix}
patch = jsonpatch.JsonPatchWithAppend.from_diff(
src, dst, generate_test_ops=generate_tests)
self.assertEqual(patch.patch[-1]['op'], 'append')
self.assertEqual(patch.patch[-1]['value'], suffix)
self.assertEqual(patch.apply(src), dst)

@given(pair=doc_pairs)
def test_generated_patch_round_trip(self, pair):
src, dst = pair
patch = jsonpatch.JsonPatchWithAppend.from_diff(src, dst)
restored = jsonpatch.JsonPatchWithAppend.from_string(patch.to_string())
assert_json_equal(restored.apply(src), dst)


PatchOperationTests = PatchOperationMachine.TestCase


Expand Down
Loading
Loading