Enhance fact_diff to show common lines (#323)

* Enhance fact_diff to show common lines

* Add integration and unit tests

* Fix sanity

---------

Co-authored-by: Ashwini Mhatre <amhatre@amhatre-thinkpadt14sgen2i.pnq.csb>
pull/326/head^2
Ashwini Mhatre 2024-01-16 17:55:39 +05:30 committed by GitHub
parent 259cb8b9e4
commit 92903a3a58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 166 additions and 58 deletions

View File

@ -0,0 +1,3 @@
---
minor_changes:
- Add support in fact_diff filter plugin to show common lines.(https://github.com/ansible-collections/ansible.utils/issues/311)

View File

@ -70,6 +70,27 @@ Parameters
<div>The first fact to be used in the comparison.</div> <div>The first fact to be used in the comparison.</div>
</td> </td>
</tr> </tr>
<tr>
<td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>common</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
<span style="color: purple">boolean</span>
</div>
</td>
<td>
<ul style="margin: 0; padding: 0"><b>Choices:</b>
<li><div style="color: blue"><b>no</b>&nbsp;&larr;</div></li>
<li>yes</li>
</ul>
</td>
<td>
</td>
<td>
<div>Show all common lines.</div>
</td>
</tr>
<tr> <tr>
<td colspan="3"> <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div> <div class="ansibleOptionAnchor" id="parameter-"></div>

View File

@ -59,6 +59,11 @@ DOCUMENTATION = """
comparison comparison
type: list type: list
elements: str elements: str
common:
description:
- Show all common lines.
type: bool
default: false
""" """
EXAMPLES = """ EXAMPLES = """
@ -185,7 +190,7 @@ except ImportError:
def _fact_diff(*args, **kwargs): def _fact_diff(*args, **kwargs):
"""Find the difference between currently set facts""" """Find the difference between currently set facts"""
keys = ["before", "after", "plugin"] keys = ["before", "after", "plugin", "common"]
data = dict(zip(keys, args[1:])) data = dict(zip(keys, args[1:]))
data.update(kwargs) data.update(kwargs)
aav = AnsibleArgSpecValidator(data=data, schema=DOCUMENTATION, name="fact_diff") aav = AnsibleArgSpecValidator(data=data, schema=DOCUMENTATION, name="fact_diff")

View File

@ -10,8 +10,11 @@ The fact_diff plugin code
""" """
from __future__ import absolute_import, division, print_function from __future__ import absolute_import, division, print_function
import difflib
import re import re
from collections.abc import MutableMapping
from ansible.plugins.callback import CallbackBase from ansible.plugins.callback import CallbackBase
@ -31,7 +34,7 @@ def _raise_error(msg):
raise AnsibleFilterError(error) raise AnsibleFilterError(error)
def fact_diff(before, after, plugin): def fact_diff(before, after, plugin, common):
"""Compare two facts or variables and get a diff. """Compare two facts or variables and get a diff.
:param before: The first fact to be used in the comparison. :param before: The first fact to be used in the comparison.
:type before: raw :type before: raw
@ -40,46 +43,103 @@ def fact_diff(before, after, plugin):
:param plugin: The name of the plugin in collection format :param plugin: The name of the plugin in collection format
:type plugin: string :type plugin: string
""" """
result = run_diff(before, after, plugin) if plugin.get("name") == "ansible.utils.native":
result = fact_diff_native().run_diff(before, after, plugin, common)
return result return result
def _check_valid_regexes(skip_lines): class fact_diff_native(CallbackBase):
if skip_lines: def _check_valid_regexes(self, skip_lines):
for idx, regex in enumerate(skip_lines): if skip_lines:
try: for idx, regex in enumerate(skip_lines):
skip_lines[idx] = re.compile(regex) try:
except re.error as exc: skip_lines[idx] = re.compile(regex)
msg = "The regex '{regex}', is not valid. The error was {err}.".format( except re.error as exc:
regex=regex, msg = "The regex '{regex}', is not valid. The error was {err}.".format(
err=str(exc), regex=regex,
err=str(exc),
)
_raise_error(msg)
def _xform(self, before, after, skip_lines):
if skip_lines:
if isinstance(before, str):
before = before.splitlines()
if isinstance(after, str):
after = after.splitlines()
before = [
line for line in before if not any(regex.match(str(line)) for regex in skip_lines)
]
after = [
line for line in after if not any(regex.match(str(line)) for regex in skip_lines)
]
if isinstance(before, list):
before = "\n".join(map(str, before)) + "\n"
if isinstance(after, list):
after = "\n".join(map(str, after)) + "\n"
return before, after, skip_lines
def get_fact_diff(self, difflist):
if not isinstance(difflist, list):
difflist = [difflist]
ret = []
for diff in difflist:
if "before" in diff and "after" in diff:
# format complex structures into 'files'
for x in ["before", "after"]:
if isinstance(diff[x], MutableMapping):
diff[x] = self._serialize_diff(diff[x])
elif diff[x] is None:
diff[x] = ""
if "before_header" in diff:
before_header = "before: %s" % diff["before_header"]
else:
before_header = "before"
if "after_header" in diff:
after_header = "after: %s" % diff["after_header"]
else:
after_header = "after"
before_lines = diff["before"].splitlines(True)
after_lines = diff["after"].splitlines(True)
if before_lines and not before_lines[-1].endswith("\n"):
before_lines[-1] += "\n\\ No newline at end of file\n"
if after_lines and not after_lines[-1].endswith("\n"):
after_lines[-1] += "\n\\ No newline at end of file\n"
diff_context = (
len(before_lines) if len(before_lines) > len(after_lines) else len(after_lines)
) )
_raise_error(msg) differ = difflib.unified_diff(
before_lines,
after_lines,
fromfile=before_header,
tofile=after_header,
fromfiledate="",
tofiledate="",
n=diff_context,
)
difflines = list(differ)
has_diff = False
for line in difflines:
has_diff = True
if diff["common"]:
if line.startswith("+") or line.startswith("-"):
pass
else:
ret.append(line)
else:
ret.append(line)
if has_diff:
ret.append("\n")
if "prepared" in diff:
ret.append(diff["prepared"])
return "".join(ret)
def run_diff(self, before, after, plugin, common):
def _xform(before, after, skip_lines): skip_lines = plugin["vars"].get("skip_lines")
if skip_lines: self._check_valid_regexes(skip_lines=skip_lines)
if isinstance(before, str): before, after, skip_lines = self._xform(before, after, skip_lines=skip_lines)
before = before.splitlines() diff = self.get_fact_diff({"before": before, "after": after, "common": common})
if isinstance(after, str): ansi_escape = re.compile(r"\x1B[@-_][0-?]*[ -/]*[@-~]")
after = after.splitlines() diff_text = ansi_escape.sub("", diff)
before = [ result = list(diff_text.splitlines())
line for line in before if not any(regex.match(str(line)) for regex in skip_lines) return result
]
after = [line for line in after if not any(regex.match(str(line)) for regex in skip_lines)]
if isinstance(before, list):
before = "\n".join(map(str, before)) + "\n"
if isinstance(after, list):
after = "\n".join(map(str, after)) + "\n"
return before, after, skip_lines
def run_diff(before, after, plugin):
skip_lines = plugin["vars"].get("skip_lines")
_check_valid_regexes(skip_lines=skip_lines)
before, after, skip_lines = _xform(before, after, skip_lines=skip_lines)
diff = CallbackBase()._get_diff({"before": before, "after": after})
ansi_escape = re.compile(r"\x1B[@-_][0-?]*[ -/]*[@-~]")
diff_text = ansi_escape.sub("", diff)
result = list(diff_text.splitlines())
return result

View File

@ -20,28 +20,16 @@
msg: "The regex '+', is not valid" msg: "The regex '+', is not valid"
- name: Check argspec validation - name: Check argspec validation
ansible.builtin.set_fact: "{{ ansible.utils.fact_diff([1, 2, 3]) }}" ansible.builtin.set_fact:
result: "{{ [1, 2, 3] | ansible.utils.fact_diff() }}"
register: error
ignore_errors: true ignore_errors: true
register: result
- name: Assert - name: Assert
ansible.builtin.assert: ansible.builtin.assert:
that: "{{ msg in result.msg }}" that: "{{ msg in error.msg }}"
vars: vars:
msg: "missing required arguments: after" msg: "missing required arguments: after"
when: "result.msg | type_debug == 'list'"
- name: Check argspec validation
ansible.builtin.set_fact: "{{ ansible.utils.fact_diff([1, 2, 3]) }}"
ignore_errors: true
register: result
- name: Assert
ansible.builtin.assert:
that: "{{ msg in result.msg }}"
vars:
msg: "missing required arguments: before"
when: "result.msg | type_debug == 'list'"
- name: Set fact - name: Set fact
ansible.builtin.set_fact: ansible.builtin.set_fact:
@ -69,7 +57,7 @@
before: "{{ before | ansible.utils.to_paths }}" before: "{{ before | ansible.utils.to_paths }}"
after: "{{ after | ansible.utils.to_paths }}" after: "{{ after | ansible.utils.to_paths }}"
- name: Show the difference in json format - name: Show the difference in path format
ansible.builtin.set_fact: ansible.builtin.set_fact:
result: "{{ before | ansible.utils.fact_diff(after) }}" result: "{{ before | ansible.utils.fact_diff(after) }}"
@ -78,6 +66,27 @@
before: "{{ before | to_nice_yaml }}" before: "{{ before | to_nice_yaml }}"
after: "{{ after | to_nice_yaml }}" after: "{{ after | to_nice_yaml }}"
- name: Show the difference in json format - name: Show the difference in yaml format
ansible.builtin.set_fact: ansible.builtin.set_fact:
result: "{{ before | ansible.utils.fact_diff(after) }}" result: "{{ before | ansible.utils.fact_diff(after) }}"
- name: Set fact
ansible.builtin.set_fact:
before:
a:
b:
c:
d:
- 0
- 1
after:
a:
b:
c:
d:
- 2
- 3
- name: Show the common lines in json format
ansible.builtin.set_fact:
result: "{{ before | ansible.utils.fact_diff(after, common=true) }}"

View File

@ -106,3 +106,13 @@ class TestUpdate_Fact(unittest.TestCase):
"missing required arguments: after", "missing required arguments: after",
str(error.exception), str(error.exception),
) )
def test_diff_dict_common(self):
"""Compare two dicts that with common option"""
self.maxDiff = None
before = {"a": {"b": {"c": {"d": [0, 1, 2, 3]}}}}
after = {"a": {"b": {"c": {"d": [0, 1, 2, 4]}}}}
result = _fact_diff("", before, after, common=True)
self.assertIn(" 0", result)
self.assertIn(" 1", result)
self.assertIn(" 2", result)