mirror of
https://github.com/ansible-collections/community.general.git
synced 2025-07-28 07:31:23 -07:00
[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:
parent
e9b58cfc09
commit
c72d8d4b56
10 changed files with 983 additions and 1 deletions
56
plugins/filter/json_diff.yml
Normal file
56
plugins/filter/json_diff.yml
Normal 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"}
|
||||
# ]
|
195
plugins/filter/json_patch.py
Normal file
195
plugins/filter/json_patch.py
Normal 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,
|
||||
}
|
145
plugins/filter/json_patch.yml
Normal file
145
plugins/filter/json_patch.yml
Normal 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
|
102
plugins/filter/json_patch_recipe.yml
Normal file
102
plugins/filter/json_patch_recipe.yml
Normal 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]}
|
Loading…
Add table
Add a link
Reference in a new issue