diff --git a/changes/130.added b/changes/130.added new file mode 100644 index 0000000..b79276f --- /dev/null +++ b/changes/130.added @@ -0,0 +1 @@ +Add the ability to reconstruct JSON blobs to perform JSON data compliance. diff --git a/docs/code-reference/jdiff/__init__.md b/docs/code-reference/jdiff/__init__.md new file mode 100644 index 0000000..b811229 --- /dev/null +++ b/docs/code-reference/jdiff/__init__.md @@ -0,0 +1 @@ +::: jdiff diff --git a/docs/code-reference/jdiff/check_types.md b/docs/code-reference/jdiff/check_types.md new file mode 100644 index 0000000..c516f6f --- /dev/null +++ b/docs/code-reference/jdiff/check_types.md @@ -0,0 +1 @@ +::: jdiff.check_types diff --git a/docs/code-reference/jdiff/evaluators.md b/docs/code-reference/jdiff/evaluators.md new file mode 100644 index 0000000..6fbf718 --- /dev/null +++ b/docs/code-reference/jdiff/evaluators.md @@ -0,0 +1 @@ +::: jdiff.evaluators diff --git a/docs/code-reference/jdiff/extract_data.md b/docs/code-reference/jdiff/extract_data.md new file mode 100644 index 0000000..76d8840 --- /dev/null +++ b/docs/code-reference/jdiff/extract_data.md @@ -0,0 +1 @@ +::: jdiff.extract_data diff --git a/docs/code-reference/jdiff/operator.md b/docs/code-reference/jdiff/operator.md new file mode 100644 index 0000000..ed20ee9 --- /dev/null +++ b/docs/code-reference/jdiff/operator.md @@ -0,0 +1 @@ +::: jdiff.operator diff --git a/docs/code-reference/jdiff/utils/__init__.md b/docs/code-reference/jdiff/utils/__init__.md new file mode 100644 index 0000000..9f026c8 --- /dev/null +++ b/docs/code-reference/jdiff/utils/__init__.md @@ -0,0 +1 @@ +::: jdiff.utils diff --git a/docs/code-reference/jdiff/utils/data_normalization.md b/docs/code-reference/jdiff/utils/data_normalization.md new file mode 100644 index 0000000..6f08a0b --- /dev/null +++ b/docs/code-reference/jdiff/utils/data_normalization.md @@ -0,0 +1 @@ +::: jdiff.utils.data_normalization diff --git a/docs/code-reference/jdiff/utils/diff_helpers.md b/docs/code-reference/jdiff/utils/diff_helpers.md new file mode 100644 index 0000000..9760b57 --- /dev/null +++ b/docs/code-reference/jdiff/utils/diff_helpers.md @@ -0,0 +1 @@ +::: jdiff.utils.diff_helpers diff --git a/docs/code-reference/jdiff/utils/jmespath_parsers.md b/docs/code-reference/jdiff/utils/jmespath_parsers.md new file mode 100644 index 0000000..aeee717 --- /dev/null +++ b/docs/code-reference/jdiff/utils/jmespath_parsers.md @@ -0,0 +1 @@ +::: jdiff.utils.jmespath_parsers diff --git a/docs/generate_code_reference_pages.py b/docs/generate_code_reference_pages.py index 0f1bed3..636ab53 100644 --- a/docs/generate_code_reference_pages.py +++ b/docs/generate_code_reference_pages.py @@ -4,7 +4,7 @@ import mkdocs_gen_files -for file_path in Path("pyntc").rglob("*.py"): +for file_path in Path("jdiff").rglob("*.py"): module_path = file_path.with_suffix("") doc_path = file_path.with_suffix(".md") full_doc_path = Path("code-reference", doc_path) diff --git a/docs/images/jdiff_logo.png b/docs/images/jdiff_logo.png new file mode 100644 index 0000000..032a750 Binary files /dev/null and b/docs/images/jdiff_logo.png differ diff --git a/docs/user/usage.md b/docs/user/usage.md index ce7692a..7ebdfd3 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -610,3 +610,118 @@ Can you guess what would be the outcome for an `int`, `float` operator? ``` See `tests` folder in the repo for more examples. + +## Putting a Result Back Together + +Jdiff results are very helpful in determining what is wrong with the outputs. What if you want to reconstruct the results in order to fix the problem. The `parse_diff` helper does just that. Imagine you have a `jdiff` result such as: + +```python +ex1 = {'bar-2': 'missing', 'bar-1': 'new'} +ex2 = { + 'hostname': {'new_value': 'veos-actual', 'old_value': 'veos-intended'}, + 'domain-name': 'new' + } +ex3 = { + 'hostname': {'new_value': 'veos-0', 'old_value': 'veos'}, + "index_element['ip name']": 'missing', + 'domain-name': 'new' + } +ex4 = { + 'servers': + { + 'server': defaultdict(, + { + 'missing': [ + { + 'address': '1.us.pool.ntp.org', + 'config': {'address': '1.us.pool.ntp.org'}, + 'state': {'address': '1.us.pool.ntp.org'} + } + ] + } + ) + } + } +``` + +And you need to understand what is extra and what is missing from the result. (Think configuration compliance on a JSON/JSON-RPC system). + +Well running the `parse_diff` will give you what is extra (in the comparison data) and missing from the reference data, and also the reverse. What is missing (in the reference data) that is missing from the comparison data. + +An example will help visualize the results. + +```python +In [1]: from jdiff import extract_data_from_json + ...: from jdiff.check_types import CheckType + ...: from jdiff.utils.diff_helpers import parse_diff + +In [2]: reference_data = {"foo": {"bar-2": "baz2"}} + ...: comparison_data = {"foo": {"bar-1": "baz1"}} + ...: match_key = "foo" + +In [3]: extracted_comparison_data = extract_data_from_json(comparison_data, match_key) + +In [4]: extracted_comparison_data +Out[4]: {'bar-1': 'baz1'} + +In [5]: extracted_reference_data = extract_data_from_json(reference_data, match_key) + +In [6]: extracted_reference_data +Out[6]: {'bar-2': 'baz2'} + +In [7]: jdiff_exact_match = CheckType.create("exact_match") + ...: jdiff_evaluate_response, _ = jdiff_exact_match.evaluate(extracted_reference_data, extracted_comparison_data) + +In [8]: jdiff_evaluate_response +Out[8]: {'bar-2': 'missing', 'bar-1': 'new'} + +In [9]: parsed_extra, parsed_missing = parse_diff( + ...: jdiff_evaluate_response, + ...: comparison_data, + ...: reference_data, + ...: match_key, + ...: ) + ...: + +In [10]: parsed_extra +Out[10]: {'bar-1': 'baz1'} + +In [10]: parsed_missing +Out[10]: {'bar-2': 'baz2'} +``` + +What about one with a more true JSON data structure. Like this RESTCONF YANG response. + +```python +from jdiff import extract_data_from_json +from jdiff.check_types import CheckType +from jdiff.utils.diff_helpers import parse_diff + +reference_data = {"openconfig-system:config": {"hostname": "veos", "ip name": "ntc.com"}} +comparison_data = {"openconfig-system:config": {"domain-name": "ntc.com", "hostname": "veos-0"}} +match_key = '"openconfig-system:config"' +extracted_comparison_data = extract_data_from_json(comparison_data, match_key) +extracted_reference_data = extract_data_from_json(reference_data, match_key) +jdiff_exact_match = CheckType.create("exact_match") +jdiff_evaluate_response, _ = jdiff_exact_match.evaluate(extracted_reference_data, extracted_comparison_data) + +parsed_extra, parsed_missing = parse_diff( + jdiff_evaluate_response, + comparison_data, + reference_data, + match_key, +) +``` +Which results in: + +```python +In [24]: parsed_extra +{'hostname': 'veos-0', 'domain-name': 'ntc.com'} + +In [25]: parsed_missing +Out[25]: {'hostname': 'veos', 'ip name': 'ntc.com'} +``` + +Now you can see how valuable this data can be to reconstruct, or remediate a out of compliant JSON object. + +For more detailed examples see the `test_diff_helpers.py` file. diff --git a/jdiff/check_types.py b/jdiff/check_types.py index 00422bf..164159f 100644 --- a/jdiff/check_types.py +++ b/jdiff/check_types.py @@ -37,8 +37,8 @@ def evaluate(self, *args, **kwargs) -> Tuple[Dict, bool]: This method is the one that each CheckType has to implement. Args: - *args: arguments specific to child class implementation - **kwargs: named arguments + *args (tuple): arguments specific to child class implementation + **kwargs (dict): named arguments Returns: tuple: Dictionary representing check result, bool indicating if differences are found. diff --git a/jdiff/extract_data.py b/jdiff/extract_data.py index d052da4..9190401 100644 --- a/jdiff/extract_data.py +++ b/jdiff/extract_data.py @@ -51,7 +51,10 @@ def extract_data_from_json(data: Union[Mapping, List], path: str = "*", exclude: if len(re.findall(r"\$.*?\$", path)) > 1: clean_path = path.replace("$", "") values = jmespath.search(f"{clean_path}{' | []' * (path.count('*') - 1)}", data) - return keys_values_zipper(multi_reference_keys(path, data), associate_key_of_my_value(clean_path, values)) + return keys_values_zipper( + multi_reference_keys(path, data), + associate_key_of_my_value(clean_path, values), + ) values = jmespath.search(jmespath_value_parser(path), data) diff --git a/jdiff/utils/diff_helpers.py b/jdiff/utils/diff_helpers.py index f94df9f..706961e 100644 --- a/jdiff/utils/diff_helpers.py +++ b/jdiff/utils/diff_helpers.py @@ -2,7 +2,8 @@ import re from collections import defaultdict -from functools import partial +from functools import partial, reduce +from operator import getitem from typing import DefaultDict, Dict, List, Mapping REGEX_PATTERN_RELEVANT_KEYS = r"'([A-Za-z0-9_\./\\-]*)'" @@ -12,10 +13,10 @@ def get_diff_iterables_items(diff_result: Mapping) -> DefaultDict: """Helper function for diff_generator to postprocess changes reported by DeepDiff for iterables. DeepDiff iterable_items are returned when the source data is a list - and provided in the format: "root['Ethernet3'][1]" - or more generically: root['KEY']['KEY']['KEY']...[numeric_index] + and provided in the format: `"root['Ethernet3'][1]"` + or more generically: `root['KEY']['KEY']['KEY']...[numeric_index]` where the KEYs are dict keys within the original object - and the "[index]" is appended to indicate the position within the list. + and the `"[index]"` is appended to indicate the position within the list. Args: diff_result: iterable comparison result from DeepDiff @@ -51,10 +52,12 @@ def fix_deepdiff_key_names(obj: Mapping) -> Dict: Args: obj (Mapping): Mapping to be fixed. For example: + ``` { "root[3]['7.7.7.7']['is_enabled']": {'new_value': False, 'old_value': True}, "root[3]['7.7.7.7']['is_up']": {'new_value': False, 'old_value': True} } + ``` Returns: Dict: aggregated output, for example: {'7.7.7.7': {'is_enabled': {'new_value': False, 'old_value': True}, @@ -86,3 +89,101 @@ def dict_merger(original_dict: Dict, dict_to_merge: Dict): original_dict[key + "_dup!"] = dict_to_merge[key] # avoid overwriting existing keys. else: original_dict[key] = dict_to_merge[key] + + +def _parse_index_element_string(index_element_string): + """Build out dictionary from the index element string.""" + result = {} + pattern = r"\[\'(.*?)\'\]" + match = re.findall(pattern, index_element_string) + if match: + for inner_key in match[1::]: + result[inner_key] = "" + return match, result + + +def set_nested_value(data, keys, value): + """ + Recursively sets a value in a nested dictionary, given a list of keys. + + Args: + data (dict): The nested dictionary to modify. + keys (list): A list of keys to access the target value. + value (str): The value to set. + + Returns: + None (None): The function modifies the dictionary in place. Returns None. + """ + if not keys: + return # Should not happen, but good to have. + if len(keys) == 1: + data[keys[0]] = value + else: + if keys[0] not in data: + data[keys[0]] = {} # Create the nested dictionary if it doesn't exist + set_nested_value(data[keys[0]], keys[1:], value) + + +def parse_diff(jdiff_evaluate_response, actual, intended, match_config): + """Parse jdiff evaluate result into missing and extra dictionaries. + + Dict value in jdiff_evaluate_response can be: + - 'missing' -> In the intended but missing from actual. + - 'new' -> In the actual missing from intended. + + Examples of jdiff_evaluate_response: + - {'bar-2': 'missing', 'bar-1': 'new'} + - {'hostname': {'new_value': 'veos-actual', 'old_value': 'veos-intended'}, 'domain-name': 'new'} + - {'hostname': {'new_value': 'veos-0', 'old_value': 'veos'}, "index_element['ip name']": 'missing', 'domain-name': 'new'} + - {'servers': {'server': defaultdict(, {'missing': [{'address': '1.us.pool.ntp.org', 'config': {'address': '1.us.pool.ntp.org'}, 'state': {'address': '1.us.pool.ntp.org'}}]})}} + """ + # Remove surrounding double quotes if present from jmespath/config-to-match match with - in the string. + match_config = match_config.strip('"') + extra = {} # In the actual missing from intended. + missing = {} # In the intended but missing from actual. + + def process_diff(_map, extra_map, missing_map, previous_key=None): + """Process the diff recursively.""" + for key, value in _map.items(): + if isinstance(value, dict) and all(nested_key in value for nested_key in ("new_value", "old_value")): + extra_map[key] = value["new_value"] + missing_map[key] = value["old_value"] + elif isinstance(value, str): + if "missing" in value and "index_element" in key: + key_chain, _ = _parse_index_element_string(key) + if len(key_chain) == 1: + missing_map[key_chain[0]] = intended.get(match_config, {}).get(key_chain[0]) + else: + new_value = reduce(getitem, key_chain, intended) + set_nested_value(extra_map, key_chain[1::], new_value) + elif "missing" in value: + missing_map[key] = intended.get(match_config, {}).get(key) + else: + if "new" in value: + extra_map[key] = actual.get(match_config, {}).get(key) + elif isinstance(value, defaultdict): + value_dict = dict(value) + if "new" in value_dict: + extra_map[previous_key][key] = value_dict.get("new", {}) + if "missing" in value_dict: + missing_map[previous_key][key] = value_dict.get("missing", {}) + elif isinstance(value, dict): + extra_map[key] = {} + missing_map[key] = {} + process_diff(value, extra_map, missing_map, previous_key=key) + return extra_map, missing_map + + extras, missing = process_diff(jdiff_evaluate_response, extra, missing) + # Don't like this, but with less the performant way of doing it right now it works to clear out + # Any empty dicts that are left over from the diff. + final_extras = extras.copy() + final_missing = missing.copy() + for key, value in extras.items(): + if isinstance(value, dict): + if not value: + del final_extras[key] + for key, value in missing.items(): + if isinstance(value, dict): + if not value: + del final_missing[key] + return final_extras, final_missing diff --git a/jdiff/utils/jmespath_parsers.py b/jdiff/utils/jmespath_parsers.py index e5fe681..d9c8644 100644 --- a/jdiff/utils/jmespath_parsers.py +++ b/jdiff/utils/jmespath_parsers.py @@ -135,10 +135,10 @@ def multi_reference_keys(jmspath: str, data): """Build a list of concatenated reference keys. Args: - jmspath: "$*$.peers.$*$.*.ipv4.[accepted_prefixes]" - data: tests/mock/napalm_get_bgp_neighbors/multi_vrf.json + jmspath (str): "$*$.peers.$*$.*.ipv4.[accepted_prefixes]" + data (dict): tests/mock/napalm_get_bgp_neighbors/multi_vrf.json - Returns: + Returns (str): ["global.10.1.0.0", "global.10.2.0.0", "global.10.64.207.255", "global.7.7.7.7", "vpn.10.1.0.0", "vpn.10.2.0.0"] """ ref_key_regex = re.compile(r"\$.*?\$") diff --git a/mkdocs.yml b/mkdocs.yml index 5d4200a..031c194 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -139,3 +139,13 @@ nav: - Contributing to the Library: "dev/contributing.md" - Development Environment: "dev/dev_environment.md" - Architecture Decisions: "dev/arch_decision.md" + - Code Reference: + - Jdiff: "code-reference/jdiff/__init__.md" + - check_types: "code-reference/jdiff/check_types.md" + - evaluators: "code-reference/jdiff/evaluators.md" + - extract_data: "code-reference/jdiff/extract_data.md" + - operator: "code-reference/jdiff/operator.md" + - jdiff_utils: "code-reference/jdiff/utils/__init__.md" + - data_normalization: "code-reference/jdiff/utils/data_normalization.md" + - diff_helpers: "code-reference/jdiff/utils/diff_helpers.md" + - jmespath_parsers: "code-reference/jdiff/utils/jmespath_parsers.md" diff --git a/tests/test_diff_helpers.py b/tests/test_diff_helpers.py index d20028f..b4a563a 100644 --- a/tests/test_diff_helpers.py +++ b/tests/test_diff_helpers.py @@ -1,6 +1,17 @@ """DIff helpers unit tests.""" -from jdiff.utils.diff_helpers import dict_merger, fix_deepdiff_key_names, get_diff_iterables_items, group_value +import pytest + +from jdiff import extract_data_from_json +from jdiff.check_types import CheckType +from jdiff.utils.diff_helpers import ( + _parse_index_element_string, + dict_merger, + fix_deepdiff_key_names, + get_diff_iterables_items, + group_value, + parse_diff, +) def test_dict_merger(): @@ -38,10 +49,149 @@ def test_get_diff_iterables_items(): diff_result = { "values_changed": {"root['Ethernet1'][0]['port']": {"new_value": "518", "old_value": "519"}}, "iterable_item_added": { - "root['Ethernet3'][1]": {"hostname": "ios-xrv-unittest", "port": "Gi0/0/0/0"}, + "root['Ethernet3'][1]": { + "hostname": "ios-xrv-unittest", + "port": "Gi0/0/0/0", + }, }, } result = get_diff_iterables_items(diff_result) assert list(dict(result).keys())[0] == "['Ethernet3']" assert list(list(dict(result).values())[0].values())[0] == [{"hostname": "ios-xrv-unittest", "port": "Gi0/0/0/0"}] + + +index_element_case_1 = ( + "index_element['foo']['ip name']", + {"ip name": ""}, +) + +index_element_case_2 = ( + "index_element['foo']['ip name']['ip domain']", + {"ip name": "", "ip domain": ""}, +) + + +index_element_tests = [index_element_case_1, index_element_case_2] + + +@pytest.mark.parametrize("index_element, result", index_element_tests) +def test__parse_index_element_string(index_element, result): + """Test that index_element can be unpacked.""" + _, parsed_result = _parse_index_element_string(index_element) + assert parsed_result == result + + +parse_diff_simple_1 = ( + {"foo": {"bar-1": "baz1"}}, # actual + {"foo": {"bar-2": "baz2"}}, # intended + "foo", # match_config + {"bar-1": "baz1"}, # extra + {"bar-2": "baz2"}, # missing +) + +parse_diff_case_1 = ( + {"openconfig-system:config": {"domain-name": "ntc.com", "hostname": "veos-actual"}}, # actual + {"openconfig-system:config": {"hostname": "veos-intended"}}, # intended + '"openconfig-system:config"', # match_config + {"hostname": "veos-actual", "domain-name": "ntc.com"}, # extra + {"hostname": "veos-intended"}, # missing +) + +parse_diff_case_2 = ( + {"openconfig-system:config": {"domain-name": "ntc.com", "hostname": "veos-0"}}, # actual + {"openconfig-system:config": {"hostname": "veos", "ip name": "ntc.com"}}, # intended + '"openconfig-system:config"', # match_config + {"domain-name": "ntc.com", "hostname": "veos-0"}, # extra + {"hostname": "veos", "ip name": "ntc.com"}, # missing +) + +parse_diff_case_3 = ( + {"openconfig-system:config": {"domain-name": "ntc.com", "hostname": "veos-0"}}, + {"openconfig-system:config": {"ip name": "ntc.com"}}, + '"openconfig-system:config"', + {"domain-name": "ntc.com", "hostname": "veos-0"}, + {"ip name": "ntc.com"}, +) + +parse_diff_case_4 = ( + {"openconfig-system:config": {"domain-name": "ntc.com", "hostname": "veos"}}, + {"openconfig-system:config": {"hostname": "veos"}}, + '"openconfig-system:config"', + {"domain-name": "ntc.com"}, + {}, +) + +parse_diff_case_5 = ( + {"openconfig-system:config": {"domain-name": "ntc.com", "hostname": "veos-0"}}, + {"openconfig-system:config": {"hostname": "veos", "ip name": "ntc.com"}}, + '"openconfig-system:config"', + {"hostname": "veos-0", "domain-name": "ntc.com"}, + {"ip name": "ntc.com", "hostname": "veos"}, +) + +parse_diff_case_6 = ( + {"openconfig-system:ntp": {"servers": {"server": []}}}, + { + "openconfig-system:ntp": { + "servers": { + "server": [ + { + "address": "1.us.pool.ntp.org", + "config": {"address": "1.us.pool.ntp.org"}, + "state": {"address": "1.us.pool.ntp.org"}, + } + ] + } + } + }, + '"openconfig-system:ntp"', + {}, + { + "servers": { + "server": [ + { + "address": "1.us.pool.ntp.org", + "config": { + "address": "1.us.pool.ntp.org", + }, + "state": { + "address": "1.us.pool.ntp.org", + }, + }, + ], + }, + }, +) + +parse_diff_tests = [ + parse_diff_simple_1, + parse_diff_case_1, + parse_diff_case_2, + parse_diff_case_3, + parse_diff_case_4, + parse_diff_case_5, + parse_diff_case_6, +] + + +@pytest.mark.parametrize( + "actual, intended, match_config, extra, missing", + parse_diff_tests, +) +def test_parse_diff(actual, intended, match_config, extra, missing): # pylint: disable=too-many-arguments + """Test that index_element can be unpacked.""" + jdiff_param_match = CheckType.create("exact_match") + extracted_actual = extract_data_from_json(actual, match_config) + extracted_intended = extract_data_from_json(intended, match_config) + jdiff_evaluate_response, _ = jdiff_param_match.evaluate(extracted_intended, extracted_actual) + print("jdiff_evaluate_response", jdiff_evaluate_response) + + parsed_extra, parsed_missing = parse_diff( + jdiff_evaluate_response, + actual, + intended, + match_config, + ) + assert parsed_extra == extra + assert parsed_missing == missing