diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 36cf0a8156..0544a08be8 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -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 diff --git a/plugins/filter/json_diff.yml b/plugins/filter/json_diff.yml new file mode 100644 index 0000000000..a370564d7a --- /dev/null +++ b/plugins/filter/json_diff.yml @@ -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"} + # ] diff --git a/plugins/filter/json_patch.py b/plugins/filter/json_patch.py new file mode 100644 index 0000000000..4600bfaf92 --- /dev/null +++ b/plugins/filter/json_patch.py @@ -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, + } diff --git a/plugins/filter/json_patch.yml b/plugins/filter/json_patch.yml new file mode 100644 index 0000000000..6fd411d6ff --- /dev/null +++ b/plugins/filter/json_patch.yml @@ -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 diff --git a/plugins/filter/json_patch_recipe.yml b/plugins/filter/json_patch_recipe.yml new file mode 100644 index 0000000000..671600b941 --- /dev/null +++ b/plugins/filter/json_patch_recipe.yml @@ -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]} diff --git a/tests/integration/targets/filter_json_patch/runme.sh b/tests/integration/targets/filter_json_patch/runme.sh new file mode 100755 index 0000000000..d591ee3289 --- /dev/null +++ b/tests/integration/targets/filter_json_patch/runme.sh @@ -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 "$@" diff --git a/tests/integration/targets/filter_json_patch/runme.yml b/tests/integration/targets/filter_json_patch/runme.yml new file mode 100644 index 0000000000..f98c70f697 --- /dev/null +++ b/tests/integration/targets/filter_json_patch/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 } diff --git a/tests/integration/targets/filter_json_patch/tasks/main.yml b/tests/integration/targets/filter_json_patch/tasks/main.yml new file mode 100644 index 0000000000..014133acad --- /dev/null +++ b/tests/integration/targets/filter_json_patch/tasks/main.yml @@ -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"} diff --git a/tests/unit/plugins/filter/test_json_patch.py b/tests/unit/plugins/filter/test_json_patch.py new file mode 100644 index 0000000000..7bd4a08664 --- /dev/null +++ b/tests/unit/plugins/filter/test_json_patch.py @@ -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", + ) diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt index 8018bc0c23..fb24975d7b 100644 --- a/tests/unit/requirements.txt +++ b/tests/unit/requirements.txt @@ -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 \ No newline at end of file +python-jenkins >= 0.4.12 + +# requirement for json_patch, json_patch_recipe and json_patch plugins +jsonpatch \ No newline at end of file