From: Christian Herdtweck Date: Tue, 4 Jul 2023 14:03:10 +0000 (+0200) Subject: Make CnfVar lists comparable X-Git-Tag: v1.7.2~1 X-Git-Url: http://developer.intra2net.com/git/?p=pyi2ncommon;a=commitdiff_plain;h=b621d44e7d2e0abe9e47c9e9f7bf5c9a4aeeaa76 Make CnfVar lists comparable Add a mixin with a compare() function to BaseCnfList that returns an instance of CnfDiff to simplify printing diffs. Add unittests for new functionality. --- diff --git a/src/cnfvar/__init__.py b/src/cnfvar/__init__.py index 9529af2..99abd49 100644 --- a/src/cnfvar/__init__.py +++ b/src/cnfvar/__init__.py @@ -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",] diff --git a/src/cnfvar/model.py b/src/cnfvar/model.py index 6e46bdd..7944d78 100644 --- a/src/cnfvar/model.py +++ b/src/cnfvar/model.py @@ -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"] diff --git a/test/cnfvar/test_model.py b/test/cnfvar/test_model.py index 3b2da00..e09587c 100644 --- a/test/cnfvar/test_model.py +++ b/test/cnfvar/test_model.py @@ -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()