Make CnfVar lists comparable
authorChristian Herdtweck <christian.herdtweck@intra2net.com>
Tue, 4 Jul 2023 14:03:10 +0000 (16:03 +0200)
committerChristian Herdtweck <christian.herdtweck@intra2net.com>
Wed, 5 Jul 2023 10:50:02 +0000 (12:50 +0200)
Add a mixin with a compare() function to BaseCnfList that returns an
instance of CnfDiff to simplify printing diffs.

Add unittests for new functionality.

src/cnfvar/__init__.py
src/cnfvar/model.py
test/cnfvar/test_model.py

index 9529af2..99abd49 100644 (file)
@@ -1,7 +1,7 @@
-from .model import Cnf, CnfList
+from .model import Cnf, CnfList, CnfDiff
 from .binary import CnfBinary
 from .store import CnfStore, BinaryCnfStore, CommitException
 from . import templates
 
 __all__ = ["Cnf", "CnfList", "CnfBinary", "CnfStore",
-           "BinaryCnfStore", "CommitException"]
+           "BinaryCnfStore", "CommitException", "CnfDiff",]
index 6e46bdd..7944d78 100644 (file)
@@ -34,6 +34,7 @@ extend them with extra functionality.
 """
 
 import json
+from typing import Sequence, Callable, Any, Tuple, cast as type_hints_pseudo_cast
 
 from . import string
 from .. import arnied_api
@@ -945,6 +946,79 @@ class CnfListQueryingMixin(BaseCnfList):
         return max([c.instance for c in self]) if len(self) > 0 else -1
 
 
+class CnfDiff(list):
+    """A list of differences between :py:class:`BaseCnfList`s"""
+
+    def add_missing(self, cnf: BaseCnf, ancestry: Sequence[BaseCnf]):
+        self.append(("-", cnf, ancestry))
+
+    def add_excess(self, cnf: BaseCnf, ancestry: Sequence[BaseCnf]):
+        self.append(("+", cnf, ancestry))
+
+    def print(self, output_func: Callable[[str], Any] = print):
+        """
+        Create a string representation of this diff and "print" it, using given function
+
+        :param output_func: Function to use for printing
+        :return: Iterator over text lines
+        """
+        for diff_type, cnf, ancestry in self:
+            ancestral_string = ' > '.join(f"{anc.name}={anc.value}" for anc in ancestry)
+            output_func(f"cnf diff: {diff_type} {cnf.name} ({cnf.instance}) = {cnf.value!r} in "
+                        + ancestral_string)
+
+
+class CnfCompareMixin(BaseCnfList):
+    """Mixin to add a `compare()` function."""
+
+    def compare(self, other: BaseCnfList, ignore_list: Sequence[str] = None,
+                ancestry: Tuple[BaseCnf, ...] = None) -> CnfDiff:
+        """
+        Compare this list of config variables to another list, return differences.
+
+        :param other: another list of configuration variables
+        :param ignore_list: names of variables to ignore
+        :param ancestry: when comparing recursively, we call this function on children (of children ...).
+                         This is the "path" of parent cnf vars that lead to the list of children we
+                         currently compare.
+        :return: difference between self and other
+        """
+        if ancestry is None:
+            ancestry = ()
+        if ignore_list is None:
+            ignore_list = ()
+        diff = CnfDiff()
+        # check whether all own values also appear in other config
+        for c_own in self:
+            c_own = type_hints_pseudo_cast(BaseCnf, c_own)
+            if c_own.name in ignore_list:
+                continue
+            c_other = other.where(lambda c: c_own == c)
+            if len(c_other) == 0:
+                diff.add_missing(c_own, ancestry)
+            elif len(c_other) == 1:
+                # found matching entry. Recurse into children
+                diff.extend(c_own.children.compare(c_other[0].children, ignore_list,
+                                                   ancestry + (c_own, )))
+            else:
+                # several variables in other have the same name, value, parent, and instance as c_own?!
+                raise NotImplementedError("This should not be possible!")
+
+        # reverse check: all other values also appear in own config?
+        for c_other in other:
+            c_other = type_hints_pseudo_cast(BaseCnf, c_other)
+            if c_other.name in ignore_list:
+                continue
+            c_own = self.where(lambda c: c_other == c)
+            if len(c_own) == 0:
+                diff.add_excess(c_other, ancestry)
+            elif len(c_own) == 1:
+                pass   # no need to descend into children again
+            else:
+                # several variables in self have the same name, value, parent, and instance as c_other?!
+                raise NotImplementedError("This should not be possible!")
+        return diff
+
 ###############################################################################
 # PUBLIC CLASSES
 ###############################################################################
@@ -953,7 +1027,7 @@ class CnfListQueryingMixin(BaseCnfList):
 #
 
 
-class CnfList(CnfListSerializationMixin, CnfListArniedApiMixin, CnfListQueryingMixin):
+class CnfList(CnfListSerializationMixin, CnfListArniedApiMixin, CnfListQueryingMixin, CnfCompareMixin):
     """Collection of Cnf variables."""
 
     pass
@@ -965,4 +1039,4 @@ class Cnf(CnfSerializationMixin, CnfArniedApiMixin, CnfShortcutsMixin):
     pass
 
 
-__all__ = ["CnfList", "Cnf"]
+__all__ = ["CnfList", "Cnf", "CnfDiff"]
index 3b2da00..e09587c 100644 (file)
@@ -30,8 +30,9 @@ import unittest
 import json
 from textwrap import dedent
 from tempfile import NamedTemporaryFile
+from copy import deepcopy
 
-from src.cnfvar import CnfList
+from src.cnfvar import CnfList, CnfDiff
 
 CNF_TEST_DATA = dedent("""\
     1 USER,1: "jake"
@@ -180,5 +181,118 @@ class TestModel(unittest.TestCase):
                  .comment = "hey this is a comment"
 
 
+class TestCompare(unittest.TestCase):
+    """Test compare() function of BaseCnfList."""
+
+    DATA_STR = dedent("""\
+        01 FRANCHISE,0: "Star Trek"
+        02     (01) SERIES,0: "The Original Series"
+        03         (02) SHIP,0: "Enterprise (original)"
+        04             (03) CAPTAIN,0: "Pike"
+        05             (03) CAPTAIN,1: "Kirk"
+        06     (01) SERIES,1: "The Animated Series"
+        07     (01) SERIES,1: "The Next Generation"
+        08         (07) SHIP,0: "Enterprise D"
+        09             (08) CAPTAIN,0: "Picard"
+        11             (08) CAPTAIN,1: "Riker"
+        12     (01) SERIES,2: "Deep Space Nine"
+        13     (01) SERIES,3: "Voyager"
+        14         (13) SHIP,0: "Voyager"
+        15     (01) SERIES,4: "Enterprise"
+        16         (15) SHIP,0: "Enterprise"
+        17             (16) CAPTAIN,0: "Archer"
+        18 FRANCHISE,1: "Star Wars"
+        19     (18) MOVIE,0: "A New Hope"
+        21         (19) EPISODE,0: "4"
+        22     (18) MOVIE,1: "The Empire Strikes Back"
+        23         (22) EPISODE,0: "5"
+        24     (18) MOVIE,2: "Return of the Jedi"
+        25         (24) EPISODE,0: "6"
+        26     (18) MOVIE,3: "The Phantom Menace"
+        27         (26) EPISODE,0: "1"
+        28     (18) MOVIE,4: "Attack of the Clones"
+        29         (28) EPISODE,0: "2"
+        31     (18) MOVIE,5: "Revenge of the Sith"
+        32         (31) EPISODE,0: "3"
+        33 FRANCHISE,2: "Battlestar Galactica"
+        34 FRANCHISE,3: "Firefly"
+        35 FRANCHISE,4: "Babylon 5"
+        36 FRANCHISE,5: "Star Gate"
+        """)
+
+    DATA: CnfList   # initialized in setUpClass
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        """Called once before first test, converts above string to CnfList."""
+        cls.DATA = CnfList.from_cnf_string(cls.DATA_STR)
+
+    def test_equal(self):
+        """Compare data to itself."""
+        self.assertEqual(CnfDiff(), self.DATA.compare(self.DATA))
+
+    def test_remove(self):
+        """Remove entry from data, check it is found."""
+        other = deepcopy(self.DATA)
+        removed = other.single_with_value("Star Trek").children\
+            .single_with_value("The Original Series").children\
+            .single_with_value("Enterprise (original)").children \
+            .remove_where(lambda cnf: cnf.value == "Pike")
+        self.assertEqual(1, len(removed))
+        diff = self.DATA.compare(other)
+        self.assertEqual(1, len(diff))
+        diff_text = []
+        diff.print(output_func=lambda s: diff_text.append(s))
+        self.assertEqual(1, len(diff_text))
+        expect = "cnf diff: - captain (0) = 'Pike' " + \
+                 "in franchise=Star Trek > series=The Original Series > ship=Enterprise (original)"
+        self.assertEqual(expect, diff_text[0])
+
+    def test_add(self):
+        """Add entry to data, check it is found."""
+        other = deepcopy(self.DATA)
+        other.single_with_value("Star Wars").children.single_with_value("The Phantom Menace")\
+            .add_child("CHARACTER", "Jar Jar Binks")
+        diff = self.DATA.compare(other)
+        self.assertEqual(1, len(diff))
+        diff_text = []
+        diff.print(output_func=lambda s: diff_text.append(s))
+        self.assertEqual(1, len(diff_text))
+        expect = "cnf diff: + CHARACTER (0) = 'Jar Jar Binks' " + \
+                 "in franchise=Star Wars > movie=The Phantom Menace"
+        self.assertEqual(expect, diff_text[0])
+
+    def test_change(self):
+        """Change entry in data, check it is found."""
+        other = deepcopy(self.DATA)
+        other.single_with_value("Star Trek").children \
+            .single_with_value("The Next Generation").children \
+            .single_with_value("Enterprise D").children \
+            .single_with_value("Picard").value = "Jean-Luc"
+        diff = self.DATA.compare(other)
+        self.assertEqual(2, len(diff))
+        diff_text = []
+        diff.print(output_func=lambda s: diff_text.append(s))
+        self.assertEqual(2, len(diff_text))
+        expect = [
+            "cnf diff: - captain (0) = 'Picard' in franchise=Star Trek > "
+                "series=The Next Generation > ship=Enterprise D",
+            "cnf diff: + captain (0) = 'Jean-Luc' in franchise=Star Trek > "
+                "series=The Next Generation > ship=Enterprise D",
+        ]
+        self.assertEqual(expect, diff_text)
+
+    def test_ignore(self):
+        """Check that changes on ignore_list are ignored."""
+        other = deepcopy(self.DATA)
+        # same change as in test_change
+        other.single_with_value("Star Trek").children \
+            .single_with_value("The Next Generation").children \
+            .single_with_value("Enterprise D").children \
+            .single_with_value("Picard").value = "Jean-Luc"
+        diff = self.DATA.compare(other, ignore_list=["CAPTAIN",])
+        self.assertEqual(0, len(diff))
+
+
 if __name__ == '__main__':
     unittest.main()