[PR #9565/f5c1b9c7 backport][stable-10] add json_patch, json_patch_recipe and json_diff filters (#9597)

add json_patch, json_patch_recipe and json_diff filters (#9565)

* add json_patch, json_patch_recipe and json_diff filters

* fix copyright notices

* fix documentation

* fix docs, add maintainer

* fix review remarks

* add integration test

* fix docs (positional)

* add input validation

* formatting fixes

* more typing tweaks

* documentation fix

* fix review comments

* simplicfy input checking

* accept bytes and bytearray input

* add the fail_test argument

* fix docs format

* fix typing hints

* remove unneeded __future__ imports

(cherry picked from commit f5c1b9c70f)

Co-authored-by: Stanislav Meduna <stano@meduna.org>
This commit is contained in:
patchback[bot] 2025-01-21 21:04:41 +01:00 committed by GitHub
commit c72d8d4b56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 983 additions and 1 deletions

View file

@ -0,0 +1,56 @@
---
# Copyright (c) Stanislav Meduna (@numo68)
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
DOCUMENTATION:
name: json_diff
short_description: Create a JSON patch by comparing two JSON files
description:
- This filter compares the input with the argument and computes a list of operations
that can be consumed by the P(community.general.json_patch_recipe#filter) to change the input
to the argument.
requirements:
- jsonpatch
version_added: 10.3.0
author:
- Stanislav Meduna (@numo68)
positional: target
options:
_input:
description: A list or a dictionary representing a source JSON object, or a string containing a JSON object.
type: raw
required: true
target:
description: A list or a dictionary representing a target JSON object, or a string containing a JSON object.
type: raw
required: true
seealso:
- name: RFC 6902
description: JavaScript Object Notation (JSON) Patch
link: https://datatracker.ietf.org/doc/html/rfc6902
- name: RFC 6901
description: JavaScript Object Notation (JSON) Pointer
link: https://datatracker.ietf.org/doc/html/rfc6901
- name: jsonpatch Python Package
description: A Python library for applying JSON patches
link: https://pypi.org/project/jsonpatch/
RETURN:
_value:
description: A list of JSON patch operations to apply.
type: list
elements: dict
EXAMPLES: |
- name: Compute a difference
ansible.builtin.debug:
msg: "{{ input | community.general.json_diff(target) }}"
vars:
input: {"foo": 1, "bar":{"baz": 2}, "baw": [1, 2, 3], "hello": "day"}
target: {"foo": 1, "bar": {"baz": 2}, "baw": [1, 3], "baq": {"baz": 2}, "hello": "night"}
# => [
# {"op": "add", "path": "/baq", "value": {"baz": 2}},
# {"op": "remove", "path": "/baw/1"},
# {"op": "replace", "path": "/hello", "value": "night"}
# ]

View file

@ -0,0 +1,195 @@
# -*- coding: utf-8 -*-
# Copyright (c) Stanislav Meduna (@numo68)
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
from json import loads
from typing import TYPE_CHECKING
from ansible.errors import AnsibleFilterError
__metaclass__ = type # pylint: disable=C0103
if TYPE_CHECKING:
from typing import Any, Callable, Union
try:
import jsonpatch
except ImportError as exc:
HAS_LIB = False
JSONPATCH_IMPORT_ERROR = exc
else:
HAS_LIB = True
JSONPATCH_IMPORT_ERROR = None
OPERATIONS_AVAILABLE = ["add", "copy", "move", "remove", "replace", "test"]
OPERATIONS_NEEDING_FROM = ["copy", "move"]
OPERATIONS_NEEDING_VALUE = ["add", "replace", "test"]
class FilterModule:
"""Filter plugin."""
def check_json_object(self, filter_name: str, object_name: str, inp: Any):
if isinstance(inp, (str, bytes, bytearray)):
try:
return loads(inp)
except Exception as e:
raise AnsibleFilterError(
f"{filter_name}: could not decode JSON from {object_name}: {e}"
) from e
if not isinstance(inp, (list, dict)):
raise AnsibleFilterError(
f"{filter_name}: {object_name} is not dictionary, list or string"
)
return inp
def check_patch_arguments(self, filter_name: str, args: dict):
if "op" not in args or not isinstance(args["op"], str):
raise AnsibleFilterError(f"{filter_name}: 'op' argument is not a string")
if args["op"] not in OPERATIONS_AVAILABLE:
raise AnsibleFilterError(
f"{filter_name}: unsupported 'op' argument: {args['op']}"
)
if "path" not in args or not isinstance(args["path"], str):
raise AnsibleFilterError(f"{filter_name}: 'path' argument is not a string")
if args["op"] in OPERATIONS_NEEDING_FROM:
if "from" not in args:
raise AnsibleFilterError(
f"{filter_name}: 'from' argument missing for '{args['op']}' operation"
)
if not isinstance(args["from"], str):
raise AnsibleFilterError(
f"{filter_name}: 'from' argument is not a string"
)
def json_patch(
self,
inp: Union[str, list, dict, bytes, bytearray],
op: str,
path: str,
value: Any = None,
**kwargs: dict,
) -> Any:
if not HAS_LIB:
raise AnsibleFilterError(
"You need to install 'jsonpatch' package prior to running 'json_patch' filter"
) from JSONPATCH_IMPORT_ERROR
args = {"op": op, "path": path}
from_arg = kwargs.pop("from", None)
fail_test = kwargs.pop("fail_test", False)
if kwargs:
raise AnsibleFilterError(
f"json_patch: unexpected keywords arguments: {', '.join(sorted(kwargs))}"
)
if not isinstance(fail_test, bool):
raise AnsibleFilterError("json_patch: 'fail_test' argument is not a bool")
if op in OPERATIONS_NEEDING_VALUE:
args["value"] = value
if op in OPERATIONS_NEEDING_FROM and from_arg is not None:
args["from"] = from_arg
inp = self.check_json_object("json_patch", "input", inp)
self.check_patch_arguments("json_patch", args)
result = None
try:
result = jsonpatch.apply_patch(inp, [args])
except jsonpatch.JsonPatchTestFailed as e:
if fail_test:
raise AnsibleFilterError(
f"json_patch: test operation failed: {e}"
) from e
else:
pass
except Exception as e:
raise AnsibleFilterError(f"json_patch: patch failed: {e}") from e
return result
def json_patch_recipe(
self,
inp: Union[str, list, dict, bytes, bytearray],
operations: list,
/,
fail_test: bool = False,
) -> Any:
if not HAS_LIB:
raise AnsibleFilterError(
"You need to install 'jsonpatch' package prior to running 'json_patch_recipe' filter"
) from JSONPATCH_IMPORT_ERROR
if not isinstance(operations, list):
raise AnsibleFilterError(
"json_patch_recipe: 'operations' needs to be a list"
)
if not isinstance(fail_test, bool):
raise AnsibleFilterError("json_patch: 'fail_test' argument is not a bool")
result = None
inp = self.check_json_object("json_patch_recipe", "input", inp)
for args in operations:
self.check_patch_arguments("json_patch_recipe", args)
try:
result = jsonpatch.apply_patch(inp, operations)
except jsonpatch.JsonPatchTestFailed as e:
if fail_test:
raise AnsibleFilterError(
f"json_patch_recipe: test operation failed: {e}"
) from e
else:
pass
except Exception as e:
raise AnsibleFilterError(f"json_patch_recipe: patch failed: {e}") from e
return result
def json_diff(
self,
inp: Union[str, list, dict, bytes, bytearray],
target: Union[str, list, dict, bytes, bytearray],
) -> list:
if not HAS_LIB:
raise AnsibleFilterError(
"You need to install 'jsonpatch' package prior to running 'json_diff' filter"
) from JSONPATCH_IMPORT_ERROR
inp = self.check_json_object("json_diff", "input", inp)
target = self.check_json_object("json_diff", "target", target)
try:
result = list(jsonpatch.make_patch(inp, target))
except Exception as e:
raise AnsibleFilterError(f"JSON diff failed: {e}") from e
return result
def filters(self) -> dict[str, Callable[..., Any]]:
"""Map filter plugin names to their functions.
Returns:
dict: The filter plugin functions.
"""
return {
"json_patch": self.json_patch,
"json_patch_recipe": self.json_patch_recipe,
"json_diff": self.json_diff,
}

View file

@ -0,0 +1,145 @@
---
# Copyright (c) Stanislav Meduna (@numo68)
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
DOCUMENTATION:
name: json_patch
short_description: Apply a JSON-Patch (RFC 6902) operation to an object
description:
- This filter applies a single JSON patch operation and returns a modified object.
- If the operation is a test, the filter returns an ummodified object if the test
succeeded and a V(none) value otherwise.
requirements:
- jsonpatch
version_added: 10.3.0
author:
- Stanislav Meduna (@numo68)
positional: op, path, value
options:
_input:
description: A list or a dictionary representing a JSON object, or a string containing a JSON object.
type: raw
required: true
op:
description: Operation to perform (see L(RFC 6902, https://datatracker.ietf.org/doc/html/rfc6902)).
type: str
choices: [add, copy, move, remove, replace, test]
required: true
path:
description: JSON Pointer path to the target location (see L(RFC 6901, https://datatracker.ietf.org/doc/html/rfc6901)).
type: str
required: true
value:
description: Value to use in the operation. Ignored for O(op=copy), O(op=move), and O(op=remove).
type: raw
from:
description: The source location for the copy and move operation. Mandatory
for O(op=copy) and O(op=move), ignored otherwise.
type: str
fail_test:
description: If V(false), a failed O(op=test) will return V(none). If V(true), the filter
invocation will fail with an error.
type: bool
default: false
seealso:
- name: RFC 6902
description: JavaScript Object Notation (JSON) Patch
link: https://datatracker.ietf.org/doc/html/rfc6902
- name: RFC 6901
description: JavaScript Object Notation (JSON) Pointer
link: https://datatracker.ietf.org/doc/html/rfc6901
- name: jsonpatch Python Package
description: A Python library for applying JSON patches
link: https://pypi.org/project/jsonpatch/
RETURN:
_value:
description: A modified object or V(none) if O(op=test), O(fail_test=false) and the test failed.
type: any
returned: always
EXAMPLES: |
- name: Insert a new element into an array at a specified index
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch('add', '/1', {'baz': 'qux'}) }}"
vars:
input: ["foo": { "one": 1 }, "bar": { "two": 2 }]
# => [{"foo": {"one": 1}}, {"baz": "qux"}, {"bar": {"two": 2}}]
- name: Insert a new key into a dictionary
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch('add', '/bar/baz', 'qux') }}"
vars:
input: { "foo": { "one": 1 }, "bar": { "two": 2 } }
# => {"foo": {"one": 1}, "bar": {"baz": "qux", "two": 2}}
- name: Input is a string
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch('add', '/baz', 3) }}"
vars:
input: '{ "foo": { "one": 1 }, "bar": { "two": 2 } }'
# => {"foo": {"one": 1}, "bar": { "two": 2 }, "baz": 3}
- name: Existing key is replaced
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch('add', '/bar', 'qux') }}"
vars:
input: { "foo": { "one": 1 }, "bar": { "two": 2 } }
# => {"foo": {"one": 1}, "bar": "qux"}
- name: Escaping tilde as ~0 and slash as ~1 in the path
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch('add', '/~0~1', 'qux') }}"
vars:
input: {}
# => {"~/": "qux"}
- name: Add at the end of the array
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch('add', '/-', 4) }}"
vars:
input: [1, 2, 3]
# => [1, 2, 3, 4]
- name: Remove a key
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch('remove', '/bar') }}"
vars:
input: { "foo": { "one": 1 }, "bar": { "two": 2 } }
# => {"foo": {"one": 1} }
- name: Replace a value
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch('replace', '/bar', 2) }}"
vars:
input: { "foo": { "one": 1 }, "bar": { "two": 2 } }
# => {"foo": {"one": 1}, "bar": 2}
- name: Copy a value
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch('copy', '/baz', from='/bar') }}"
vars:
input: { "foo": { "one": 1 }, "bar": { "two": 2 } }
# => {"foo": {"one": 1}, "bar": { "two": 2 }, "baz": { "two": 2 }}
- name: Move a value
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch('move', '/baz', from='/bar') }}"
vars:
input: { "foo": { "one": 1 }, "bar": { "two": 2 } }
# => {"foo": {"one": 1}, "baz": { "two": 2 }}
- name: Successful test
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch('test', '/bar/two', 2) | ternary('OK', 'Failed') }}"
vars:
input: { "foo": { "one": 1 }, "bar": { "two": 2 } }
# => OK
- name: Unuccessful test
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch('test', '/bar/two', 9) | ternary('OK', 'Failed') }}"
vars:
input: { "foo": { "one": 1 }, "bar": { "two": 2 } }
# => Failed

View file

@ -0,0 +1,102 @@
---
# Copyright (c) Stanislav Meduna (@numo68)
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
DOCUMENTATION:
name: json_patch_recipe
short_description: Apply JSON-Patch (RFC 6902) operations to an object
description:
- This filter sequentially applies JSON patch operations and returns a modified object.
- If there is a test operation in the list, the filter continues if the test
succeeded and returns a V(none) value otherwise.
requirements:
- jsonpatch
version_added: 10.3.0
author:
- Stanislav Meduna (@numo68)
positional: operations, fail_test
options:
_input:
description: A list or a dictionary representing a JSON object, or a string containing a JSON object.
type: raw
required: true
operations:
description: A list of JSON patch operations to apply.
type: list
elements: dict
required: true
suboptions:
op:
description: Operation to perform (see L(RFC 6902, https://datatracker.ietf.org/doc/html/rfc6902)).
type: str
choices: [add, copy, move, remove, replace, test]
required: true
path:
description: JSON Pointer path to the target location (see L(RFC 6901, https://datatracker.ietf.org/doc/html/rfc6901)).
type: str
required: true
value:
description: Value to use in the operation. Ignored for O(operations[].op=copy), O(operations[].op=move), and O(operations[].op=remove).
type: raw
from:
description: The source location for the copy and move operation. Mandatory
for O(operations[].op=copy) and O(operations[].op=move), ignored otherwise.
type: str
fail_test:
description: If V(false), a failed O(operations[].op=test) will return V(none). If V(true), the filter
invocation will fail with an error.
type: bool
default: false
seealso:
- name: RFC 6902
description: JavaScript Object Notation (JSON) Patch
link: https://datatracker.ietf.org/doc/html/rfc6902
- name: RFC 6901
description: JavaScript Object Notation (JSON) Pointer
link: https://datatracker.ietf.org/doc/html/rfc6901
- name: jsonpatch Python Package
description: A Python library for applying JSON patches
link: https://pypi.org/project/jsonpatch/
RETURN:
_value:
description: A modified object or V(none) if O(operations[].op=test), O(fail_test=false)
and the test failed.
type: any
returned: always
EXAMPLES: |
- name: Apply a series of operations
ansible.builtin.debug:
msg: "{{ input | community.general.json_patch_recipe(operations) }}"
vars:
input: {}
operations:
- op: 'add'
path: '/foo'
value: 1
- op: 'add'
path: '/bar'
value: []
- op: 'add'
path: '/bar/-'
value: 2
- op: 'add'
path: '/bar/0'
value: 1
- op: 'remove'
path: '/bar/0'
- op: 'move'
from: '/foo'
path: '/baz'
- op: 'copy'
from: '/baz'
path: '/bax'
- op: 'copy'
from: '/baz'
path: '/bay'
- op: 'replace'
path: '/baz'
value: [10, 20, 30]
# => {"bar":[2],"bax":1,"bay":1,"baz":[10,20,30]}