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
pull/9603/head
Stanislav Meduna 2025-01-21 20:51:21 +01:00 committed by GitHub
parent 0de39a6f47
commit f5c1b9c70f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 983 additions and 1 deletions

8
.github/BOTMETA.yml vendored
View File

@ -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

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]}

View File

@ -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 "$@"

View File

@ -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 }

View File

@ -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"}

View File

@ -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",
)

View File

@ -60,3 +60,6 @@ python-nomad >= 2.0.0 ; python_version >= '3.7'
# requirement for jenkins_build, jenkins_node, jenkins_plugin modules
python-jenkins >= 0.4.12
# requirement for json_patch, json_patch_recipe and json_patch plugins
jsonpatch