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__ importspull/9603/head
parent
0de39a6f47
commit
f5c1b9c70f
|
@ -164,6 +164,14 @@ files:
|
|||
maintainers: Ajpantuso
|
||||
$filters/jc.py:
|
||||
maintainers: kellyjonbrazil
|
||||
$filters/json_diff.yml:
|
||||
maintainers: numo68
|
||||
$filters/json_patch.py:
|
||||
maintainers: numo68
|
||||
$filters/json_patch.yml:
|
||||
maintainers: numo68
|
||||
$filters/json_patch_recipe.yml:
|
||||
maintainers: numo68
|
||||
$filters/json_query.py: {}
|
||||
$filters/keep_keys.py:
|
||||
maintainers: vbotka
|
||||
|
|
|
@ -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"}
|
||||
# ]
|
|
@ -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,
|
||||
}
|
|
@ -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
|
|
@ -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]}
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env bash
|
||||
# Copyright (c) Ansible Project
|
||||
# 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
|
||||
|
||||
set -eux
|
||||
|
||||
source virtualenv.sh
|
||||
|
||||
# Requirements have to be installed prior to running ansible-playbook
|
||||
# because plugins and requirements are loaded before the task runs
|
||||
|
||||
pip install jsonpatch
|
||||
|
||||
ANSIBLE_ROLES_PATH=../ ansible-playbook runme.yml "$@"
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
# Copyright (c) Ansible Project
|
||||
# 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
|
||||
|
||||
- hosts: localhost
|
||||
roles:
|
||||
- { role: filter_json_patch }
|
|
@ -0,0 +1,137 @@
|
|||
---
|
||||
####################################################################
|
||||
# WARNING: These are designed specifically for Ansible tests #
|
||||
# and should not be used as examples of how to write Ansible roles #
|
||||
####################################################################
|
||||
|
||||
# Copyright (c) Ansible Project
|
||||
# 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
|
||||
|
||||
- name: Test json_patch
|
||||
assert:
|
||||
that:
|
||||
- > # Insert a new element into an array at a specified index
|
||||
list_input |
|
||||
community.general.json_patch("add", "/1", {"baz": "qux"})
|
||||
==
|
||||
[{"foo": {"one": 1}}, {"baz": "qux"}, {"bar": {"two": 2}}]
|
||||
- > # Insert a new key into a dictionary
|
||||
dict_input |
|
||||
community.general.json_patch("add", "/bar/baz", "qux")
|
||||
==
|
||||
{"foo": {"one": 1}, "bar": {"baz": "qux", "two": 2}}
|
||||
- > # Input is a string
|
||||
'{ "foo": { "one": 1 }, "bar": { "two": 2 } }' |
|
||||
community.general.json_patch("add", "/bar/baz", "qux")
|
||||
==
|
||||
{"foo": {"one": 1}, "bar": {"baz": "qux", "two": 2}}
|
||||
- > # Existing key is replaced
|
||||
dict_input |
|
||||
community.general.json_patch("add", "/bar", "qux")
|
||||
==
|
||||
{"foo": {"one": 1}, "bar": "qux"}
|
||||
- > # Escaping tilde as ~0 and slash as ~1 in the path
|
||||
{} |
|
||||
community.general.json_patch("add", "/~0~1", "qux")
|
||||
==
|
||||
{"~/": "qux"}
|
||||
- > # Add at the end of the array
|
||||
[1, 2, 3] |
|
||||
community.general.json_patch("add", "/-", 4)
|
||||
==
|
||||
[1, 2, 3, 4]
|
||||
- > # Remove a key
|
||||
dict_input |
|
||||
community.general.json_patch("remove", "/bar")
|
||||
==
|
||||
{"foo": {"one": 1} }
|
||||
- > # Replace a value
|
||||
dict_input |
|
||||
community.general.json_patch("replace", "/bar", 2)
|
||||
==
|
||||
{"foo": {"one": 1}, "bar": 2}
|
||||
- > # Copy a value
|
||||
dict_input |
|
||||
community.general.json_patch("copy", "/baz", from="/bar")
|
||||
==
|
||||
{"foo": {"one": 1}, "bar": { "two": 2 }, "baz": { "two": 2 }}
|
||||
- > # Move a value
|
||||
dict_input |
|
||||
community.general.json_patch("move", "/baz", from="/bar")
|
||||
==
|
||||
{"foo": {"one": 1}, "baz": { "two": 2 }}
|
||||
- > # Successful test
|
||||
dict_input |
|
||||
community.general.json_patch("test", "/bar/two", 2) |
|
||||
ternary("OK", "Failed")
|
||||
==
|
||||
"OK"
|
||||
- > # Unuccessful test
|
||||
dict_input |
|
||||
community.general.json_patch("test", "/bar/two", 9) |
|
||||
ternary("OK", "Failed")
|
||||
==
|
||||
"Failed"
|
||||
vars:
|
||||
list_input:
|
||||
- foo: { one: 1 }
|
||||
- bar: { two: 2 }
|
||||
dict_input:
|
||||
foo: { one: 1 }
|
||||
bar: { two: 2 }
|
||||
|
||||
- name: Test json_patch_recipe
|
||||
assert:
|
||||
that:
|
||||
- > # List of operations
|
||||
input |
|
||||
community.general.json_patch_recipe(operations)
|
||||
==
|
||||
{"bar":[2],"bax":1,"bay":1,"baz":[10,20,30]}
|
||||
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]
|
||||
|
||||
- name: Test json_diff
|
||||
assert:
|
||||
that: # The order in the result array is not stable, sort by path
|
||||
- >
|
||||
input |
|
||||
community.general.json_diff(target) |
|
||||
sort(attribute='path')
|
||||
==
|
||||
[
|
||||
{"op": "add", "path": "/baq", "value": {"baz": 2}},
|
||||
{"op": "remove", "path": "/baw/1"},
|
||||
{"op": "replace", "path": "/hello", "value": "night"},
|
||||
]
|
||||
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"}
|
|
@ -0,0 +1,313 @@
|
|||
# -*- 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 absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type # pylint: disable=C0103
|
||||
|
||||
import unittest
|
||||
from ansible_collections.community.general.plugins.filter.json_patch import FilterModule
|
||||
from ansible.errors import AnsibleFilterError
|
||||
|
||||
|
||||
class TestJsonPatch(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.filter = FilterModule()
|
||||
self.json_patch = self.filter.filters()["json_patch"]
|
||||
self.json_diff = self.filter.filters()["json_diff"]
|
||||
self.json_patch_recipe = self.filter.filters()["json_patch_recipe"]
|
||||
|
||||
# json_patch
|
||||
|
||||
def test_patch_add_to_empty(self):
|
||||
result = self.json_patch({}, "add", "/a", 1)
|
||||
self.assertEqual(result, {"a": 1})
|
||||
|
||||
def test_patch_add_to_dict(self):
|
||||
result = self.json_patch({"b": 2}, "add", "/a", 1)
|
||||
self.assertEqual(result, {"a": 1, "b": 2})
|
||||
|
||||
def test_patch_add_to_array_index(self):
|
||||
result = self.json_patch([1, 2, 3], "add", "/1", 99)
|
||||
self.assertEqual(result, [1, 99, 2, 3])
|
||||
|
||||
def test_patch_add_to_array_last(self):
|
||||
result = self.json_patch({"a": [1, 2, 3]}, "add", "/a/-", 99)
|
||||
self.assertEqual(result, {"a": [1, 2, 3, 99]})
|
||||
|
||||
def test_patch_add_from_string(self):
|
||||
result = self.json_patch("[1, 2, 3]", "add", "/-", 99)
|
||||
self.assertEqual(result, [1, 2, 3, 99])
|
||||
|
||||
def test_patch_path_escape(self):
|
||||
result = self.json_patch({}, "add", "/x~0~1y", 99)
|
||||
self.assertEqual(result, {"x~/y": 99})
|
||||
|
||||
def test_patch_remove(self):
|
||||
result = self.json_patch({"a": 1, "b": {"c": 2}, "d": 3}, "remove", "/b")
|
||||
self.assertEqual(result, {"a": 1, "d": 3})
|
||||
|
||||
def test_patch_replace(self):
|
||||
result = self.json_patch(
|
||||
{"a": 1, "b": {"c": 2}, "d": 3}, "replace", "/b", {"x": 99}
|
||||
)
|
||||
self.assertEqual(result, {"a": 1, "b": {"x": 99}, "d": 3})
|
||||
|
||||
def test_patch_copy(self):
|
||||
result = self.json_patch(
|
||||
{"a": 1, "b": {"c": 2}, "d": 3}, "copy", "/d", **{"from": "/b"}
|
||||
)
|
||||
self.assertEqual(result, {"a": 1, "b": {"c": 2}, "d": {"c": 2}})
|
||||
|
||||
def test_patch_move(self):
|
||||
result = self.json_patch(
|
||||
{"a": 1, "b": {"c": 2}, "d": 3}, "move", "/d", **{"from": "/b"}
|
||||
)
|
||||
self.assertEqual(result, {"a": 1, "d": {"c": 2}})
|
||||
|
||||
def test_patch_test_pass(self):
|
||||
result = self.json_patch({"a": 1, "b": {"c": 2}, "d": 3}, "test", "/b/c", 2)
|
||||
self.assertEqual(result, {"a": 1, "b": {"c": 2}, "d": 3})
|
||||
|
||||
def test_patch_test_fail_none(self):
|
||||
result = self.json_patch({"a": 1, "b": {"c": 2}, "d": 3}, "test", "/b/c", 99)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_patch_test_fail_fail(self):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch(
|
||||
{"a": 1, "b": {"c": 2}, "d": 3}, "test", "/b/c", 99, fail_test=True
|
||||
)
|
||||
self.assertTrue("json_patch: test operation failed" in str(context.exception))
|
||||
|
||||
def test_patch_remove_nonexisting(self):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch({"a": 1, "b": {"c": 2}, "d": 3}, "remove", "/e")
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_patch: patch failed: can't remove a non-existent object 'e'",
|
||||
)
|
||||
|
||||
def test_patch_missing_lib(self):
|
||||
with unittest.mock.patch(
|
||||
"ansible_collections.community.general.plugins.filter.json_patch.HAS_LIB",
|
||||
False,
|
||||
):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch({}, "add", "/a", 1)
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"You need to install 'jsonpatch' package prior to running 'json_patch' filter",
|
||||
)
|
||||
|
||||
def test_patch_invalid_operation(self):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch({}, "invalid", "/a", 1)
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_patch: unsupported 'op' argument: invalid",
|
||||
)
|
||||
|
||||
def test_patch_arg_checking(self):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch(1, "add", "/a", 1)
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_patch: input is not dictionary, list or string",
|
||||
)
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch({}, 1, "/a", 1)
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_patch: 'op' argument is not a string",
|
||||
)
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch({}, None, "/a", 1)
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_patch: 'op' argument is not a string",
|
||||
)
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch({}, "add", 1, 1)
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_patch: 'path' argument is not a string",
|
||||
)
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch({}, "copy", "/a", **{"from": 1})
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_patch: 'from' argument is not a string",
|
||||
)
|
||||
|
||||
def test_patch_extra_kwarg(self):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch({}, "add", "/a", 1, invalid=True)
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_patch: unexpected keywords arguments: invalid",
|
||||
)
|
||||
|
||||
def test_patch_missing_from(self):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch({}, "copy", "/a", 1)
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_patch: 'from' argument missing for 'copy' operation",
|
||||
)
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch({}, "move", "/a", 1)
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_patch: 'from' argument missing for 'move' operation",
|
||||
)
|
||||
|
||||
def test_patch_add_to_dict_binary(self):
|
||||
result = self.json_patch(b'{"b": 2}', "add", "/a", 1)
|
||||
self.assertEqual(result, {"a": 1, "b": 2})
|
||||
result = self.json_patch(bytearray(b'{"b": 2}'), "add", "/a", 1)
|
||||
self.assertEqual(result, {"a": 1, "b": 2})
|
||||
|
||||
# json_patch_recipe
|
||||
|
||||
def test_patch_recipe_process(self):
|
||||
result = self.json_patch_recipe(
|
||||
{},
|
||||
[
|
||||
{"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]},
|
||||
{"op": "add", "path": "/foo", "value": 1},
|
||||
{"op": "add", "path": "/foo", "value": 1},
|
||||
{"op": "test", "path": "/baz/1", "value": 20},
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
result, {"bar": [2], "bax": 1, "bay": 1, "baz": [10, 20, 30], "foo": 1}
|
||||
)
|
||||
|
||||
def test_patch_recipe_test_fail(self):
|
||||
result = self.json_patch_recipe(
|
||||
{},
|
||||
[
|
||||
{"op": "add", "path": "/bar", "value": []},
|
||||
{"op": "add", "path": "/bar/-", "value": 2},
|
||||
{"op": "test", "path": "/bar/0", "value": 20},
|
||||
{"op": "add", "path": "/bar/0", "value": 1},
|
||||
],
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_patch_recipe_missing_lib(self):
|
||||
with unittest.mock.patch(
|
||||
"ansible_collections.community.general.plugins.filter.json_patch.HAS_LIB",
|
||||
False,
|
||||
):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch_recipe({}, [])
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"You need to install 'jsonpatch' package prior to running 'json_patch_recipe' filter",
|
||||
)
|
||||
|
||||
def test_patch_recipe_missing_from(self):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch_recipe({}, [{"op": "copy", "path": "/a"}])
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_patch_recipe: 'from' argument missing for 'copy' operation",
|
||||
)
|
||||
|
||||
def test_patch_recipe_incorrect_type(self):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch_recipe({}, "copy")
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_patch_recipe: 'operations' needs to be a list",
|
||||
)
|
||||
|
||||
def test_patch_recipe_test_fail_none(self):
|
||||
result = self.json_patch_recipe(
|
||||
{"a": 1, "b": {"c": 2}, "d": 3},
|
||||
[{"op": "test", "path": "/b/c", "value": 99}],
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_patch_recipe_test_fail_fail_pos(self):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch_recipe(
|
||||
{"a": 1, "b": {"c": 2}, "d": 3},
|
||||
[{"op": "test", "path": "/b/c", "value": 99}],
|
||||
True,
|
||||
)
|
||||
self.assertTrue(
|
||||
"json_patch_recipe: test operation failed" in str(context.exception)
|
||||
)
|
||||
|
||||
def test_patch_recipe_test_fail_fail_kw(self):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_patch_recipe(
|
||||
{"a": 1, "b": {"c": 2}, "d": 3},
|
||||
[{"op": "test", "path": "/b/c", "value": 99}],
|
||||
fail_test=True,
|
||||
)
|
||||
self.assertTrue(
|
||||
"json_patch_recipe: test operation failed" in str(context.exception)
|
||||
)
|
||||
|
||||
# json_diff
|
||||
|
||||
def test_diff_process(self):
|
||||
result = self.json_diff(
|
||||
{"foo": 1, "bar": {"baz": 2}, "baw": [1, 2, 3], "hello": "day"},
|
||||
{
|
||||
"foo": 1,
|
||||
"bar": {"baz": 2},
|
||||
"baw": [1, 3],
|
||||
"baq": {"baz": 2},
|
||||
"hello": "night",
|
||||
},
|
||||
)
|
||||
|
||||
# Sort as the order is unstable
|
||||
self.assertEqual(
|
||||
sorted(result, key=lambda k: k["path"]),
|
||||
[
|
||||
{"op": "add", "path": "/baq", "value": {"baz": 2}},
|
||||
{"op": "remove", "path": "/baw/1"},
|
||||
{"op": "replace", "path": "/hello", "value": "night"},
|
||||
],
|
||||
)
|
||||
|
||||
def test_diff_missing_lib(self):
|
||||
with unittest.mock.patch(
|
||||
"ansible_collections.community.general.plugins.filter.json_patch.HAS_LIB",
|
||||
False,
|
||||
):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_diff({}, {})
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"You need to install 'jsonpatch' package prior to running 'json_diff' filter",
|
||||
)
|
||||
|
||||
def test_diff_arg_checking(self):
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_diff(1, {})
|
||||
self.assertEqual(
|
||||
str(context.exception), "json_diff: input is not dictionary, list or string"
|
||||
)
|
||||
with self.assertRaises(AnsibleFilterError) as context:
|
||||
self.json_diff({}, 1)
|
||||
self.assertEqual(
|
||||
str(context.exception),
|
||||
"json_diff: target is not dictionary, list or string",
|
||||
)
|
|
@ -59,4 +59,7 @@ python-nomad < 2.0.0 ; python_version <= '3.6'
|
|||
python-nomad >= 2.0.0 ; python_version >= '3.7'
|
||||
|
||||
# requirement for jenkins_build, jenkins_node, jenkins_plugin modules
|
||||
python-jenkins >= 0.4.12
|
||||
python-jenkins >= 0.4.12
|
||||
|
||||
# requirement for json_patch, json_patch_recipe and json_patch plugins
|
||||
jsonpatch
|
Loading…
Reference in New Issue