Make CnfVar lists comparable
[pyi2ncommon] / src / cnfvar / model.py
1 # The software in this package is distributed under the GNU General
2 # Public License version 2 (with a special exception described below).
3 #
4 # A copy of GNU General Public License (GPL) is included in this distribution,
5 # in the file COPYING.GPL.
6 #
7 # As a special exception, if other files instantiate templates or use macros
8 # or inline functions from this file, or you compile this file and link it
9 # with other works to produce a work based on this file, this file
10 # does not by itself cause the resulting work to be covered
11 # by the GNU General Public License.
12 #
13 # However the source code for this file must still be made available
14 # in accordance with section (3) of the GNU General Public License.
15 #
16 # This exception does not invalidate any other reasons why a work based
17 # on this file might be covered by the GNU General Public License.
18 #
19 # Copyright (c) 2016-2022 Intra2net AG <info@intra2net.com>
20
21 """
22 model: Cnf classes, collection of Cnf classes and multiple filtering methods.
23
24 Featuring:
25     - Cnf: class representing a CNF variable
26     - CnfList: a collection of `Cnf` instances
27
28 The classes above inherit from their base types with added mixins which
29 extend them with extra functionality.
30
31 .. seealso:: Overview Diagram linked to from doc main page
32
33 .. codeauthor:: Intra2net
34 """
35
36 import json
37 from typing import Sequence, Callable, Any, Tuple, cast as type_hints_pseudo_cast
38
39 from . import string
40 from .. import arnied_api
41
42 #: value used to detect unspecified arguments
43 DEFAULT = object()
44 #: encoding used by the get_cnf and set_cnf binaries
45 ENCODING = "latin1"
46
47
48 ###############################################################################
49 # HELPERS
50 ###############################################################################
51
52
53 class CnfName(str):
54     """
55     Custom string where comparisons are case-insensitive.
56
57     With this class we do not have to worry about case when comparing against
58     the name of cnfvars when filtering. The cnfvar backend is already case-
59     insensitive anyway.
60     """
61
62     def __eq__(self, other):
63         if not isinstance(other, str):
64             return False
65         return self.lower() == other.lower()
66
67     def __contains__(self, name):
68         return name.lower() in self.lower()
69
70     def startswith(self, prefix, *args, **kwargs):
71         return self.lower().startswith(prefix.lower(), *args, **kwargs)
72
73     def endswith(self, suffix, *args, **kwargs):
74         return self.lower().endswith(suffix.lower(), *args, **kwargs)
75
76     def replace(self, old, new, *args, **kwargs):
77         return self.lower().replace(old.lower(), new.lower(), *args, **kwargs)
78
79
80 ###############################################################################
81 # BASE API
82 ###############################################################################
83
84
85 class BaseCnfList(list):
86     """Base class representing a CNF list with minimal functionality."""
87
88     def __init__(self, cnf_iter=None, renumber=False):
89         """
90         Class constructor.
91
92         :param cnf_iter: iterator producing CNF elements or arguments for the
93                          constructor of the :py:class:`Cnf` class
94         :type: :py:class:`collections.abc.Iterator` producing elements of type
95                :py:class:`Cnf`
96         :param bool renumber: whether to fix up the number/ids of the CNFs
97
98         Example::
99             cnf = Cnf("my_cnf", "value")
100             cnf_list = CnfList([
101                 cnf,
102                 ("other_cnf", "other value"),
103                 ("user", "john", instance=3)
104             ])
105         """
106         # Map the values of the iterator to support constructing this list
107         # from Cnf instances or arguments to the Cnf constructor
108         if cnf_iter is not None:
109             iter_ = map(lambda c: c if isinstance(c, Cnf) else Cnf(*c), cnf_iter)
110         else:
111             iter_ = []
112         super().__init__(iter_)
113         self._renumber_counter = None     # initialized and used in renumber
114         if renumber:
115             self.renumber()
116
117     def renumber(self):
118         """Fix line numbers of CNF variables from this list."""
119         # NOTE: we don't keep track of operations that change the list as this
120         # would require us to reimplement most of the methods. At least for now
121         # this method should be called again when serializing.
122         self._renumber_counter = 0
123
124         def renumber_fn(cnf):
125             self._renumber_counter += 1
126             cnf.lineno = self._renumber_counter
127
128         self.for_each_all(renumber_fn)
129
130     def where(self, where_filter):
131         """
132         Filter CNFs matching a given predicate.
133
134         :param where_filter: predicate to apply against CNFs
135         :type where_filter: function accepting a CNF and returning a boolean
136         :returns: an instance of this class with filtered members
137         :rtype: :py:class:`CnfList`
138         """
139         return CnfList(c for c in self if where_filter(c))
140
141     def where_child(self, where_filter):
142         """
143         Filter CNFs with children matching a given predicate.
144
145         :param where_filter: predicate to apply against children
146         :type where_filter: function accepting a CNF and returning a boolean
147         :returns: an instance of this class with filtered members
148         :rtype: :py:class:`CnfList`
149         """
150         def upper_filter(cnf):
151             return any(ch for ch in cnf.children if where_filter(ch))
152         return self.where(upper_filter)
153
154     def remove_where(self, where_filter):
155         """
156         Remove all CNFs from this list matching the given predicate.
157
158         :param where_filter: predicate to apply against children
159         :type where_filter: function accepting a CNF and returning a boolean
160         :returns: a list of the removed CNF variables
161         :rtype: [:py:class:`Cnf`]
162         """
163         r = []
164         # iterate by index for speed and in reverse to keep indexes valid
165         for i in range(len(self) - 1, -1, -1):
166             cnf = self[i]
167             if where_filter(cnf):
168                 del self[i]
169                 r.append(cnf)
170         return r
171
172     def for_each(self, fn):
173         """
174         Apply a function to each element of this list.
175
176         :param fn: function to apply to the elements
177         :type fn: function accepting a CNF (result value is ignored)
178         :returns: this same instance
179         :rtype: :py:class:`CnfList`
180
181         .. note:: this is mostly the same as the built-in map() function,
182         except that it changes the list in place.
183         """
184         for c in self:
185             try:
186                 fn(c)
187             except StopIteration:
188                 # support breaking
189                 break
190         return self
191
192     def for_each_child(self, fn):
193         """
194         Apply a function to each child of the elements of this list.
195
196         :param fn: function to apply to the elements
197         :type fn: function accepting a CNF (result value is ignored)
198         :returns: this same instance
199         :rtype: :py:class:`CnfList`
200
201         .. note:: if a CNF does not have children, it is ignored
202         """
203         for c in self:
204             children = c.children or CnfList()
205             for ch in children:
206                 try:
207                     fn(ch)
208                 except StopIteration:
209                     # support breaking
210                     break
211             # apply recursively, too
212             children.for_each_child(fn)
213         return self
214
215     def for_each_all(self, fn):
216         """
217         Apply a function to every CNF of this list, parent or child.
218
219         :param fn: function to apply to the elements
220         :type fn: function accepting a CNF (result value is ignored)
221         :returns: this same instance
222         :rtype: :py:class:`CnfList`
223         """
224         for c in self:
225             try:
226                 fn(c)
227             except StopIteration:
228                 # support breaking
229                 break
230             children = c.children or CnfList()
231             children.for_each_all(fn)
232         return self
233
234     def __str__(self):
235         """
236         Get a string representation of this instance.
237
238         :returns: a string in the cnfvar format
239         :rtype: str
240         """
241         return "\n".join((str(c) for c in self))
242
243     def __add__(self, other):
244         return CnfList(super().__add__(other))
245
246     def add(self, *args, **kwargs):
247         """
248         Add a CNF variable to the list.
249
250         Arguments can either be a single instance of the :py:class:`Cnf`
251         class or a list of arguments to be passed to the constructor of
252         that class. Similar to the :py:func:`add_child` method for a `Cnf`.
253
254         :returns: the instance that was created
255         :rtype: :py:class:`Cnf`
256         """
257         # support passing a Cnf instance
258         if len(args) == 1 and not kwargs:
259             cnf = args[0]
260             assert isinstance(cnf, Cnf), "A Cnf instance is mandatory with one argument"
261         else:
262             cnf = Cnf(*args, **kwargs)
263
264         self.append(cnf)
265         return cnf
266
267
268 class BaseCnf:
269     """Base class representing a CNF variable with minimal functionality."""
270
271     _PARENT_TEMPLATE = "{lineno} {name},{instance}: \"{value}\""
272     _CHILD_TEMPLATE = "{lineno} {indent}({parent}) {name},{instance}: \"{value}\""
273     _NEST_INDENT = "  "
274
275     def __init__(self, name, value, instance=0, parent=None,
276                  lineno=None, comment=None):
277         """
278         Create this instance.
279
280         :param str name: name of the cnfvar (case does not matter)
281         :param str value: value for this cnfvar (will be converted to string
282                           if it is not of this type)
283         :param int instance: instance of this cnfvar
284         :param parent: a parent Cnf instance
285         :type parent: :py:class:`BaseCnf`
286         :param int lineno: line number
287         :param str comment: cnfvar comment
288         """
289         self.name = CnfName(name)
290         self.value = value
291         self.instance = int(instance)
292         self.parent = parent
293         self.lineno = int(lineno or 0)
294         self.comment = comment
295         self.__children = CnfList()
296
297     # Use getters and setters to keep internal consistency and fail-fast
298     # preventing invalid data from being sent to the cnfvar backend.
299
300     def _get_name(self):
301         return self.__name
302
303     def _set_name(self, value):
304         # convert Python strings passed as name to our custom string
305         self.__name = CnfName(value)
306     name = property(_get_name, _set_name)
307
308     def _get_instance(self):
309         return self.__instance
310
311     def _set_instance(self, value):
312         # fail-fast and make sure instance is a valid integer
313         self.__instance = int(value)
314     instance = property(_get_instance, _set_instance)
315
316     def _get_lineno(self):
317         return self.__lineno
318
319     def _set_lineno(self, value):
320         # fail-fast and make sure lineno is a valid integer
321         self.__lineno = int(value)
322     lineno = property(_get_lineno, _set_lineno)
323
324     def _get_children(self):
325         return self.__children
326     # No setter to sure that the children property will not
327     # be replaced by something other than a `CnfList`
328     children = property(_get_children)
329
330     def _get_value(self):
331         return self.__value
332
333     def _set_value(self, value):
334         # Make sure the value is always stored as a string
335         # (no other types make sense to the cnfvar backend)
336         self.__value = str(value)
337     value = property(_get_value, _set_value)
338
339     def add_child(self, *args, **kwargs):
340         """
341         Add a child CNF variable.
342
343         Arguments can either be a single instance of the :py:class:`Cnf`
344         class or a list of arguments to be passed to the constructor of
345         that class.
346
347         :returns: the instance that was created
348         :rtype: :py:class:`Cnf`
349
350         Example::
351
352             cnf = Cnf("my_parent_cnf", "parent")
353             cnf2 = Cnf("my_child_cnf", "john")
354
355             # adding a child as a CNF instance
356             cnf.add_child(cnf2)
357
358             # adding a child passing arguments of the Cnf constructor
359             cnf.add_child("my_child_cnf", "jane", instance=2)
360         """
361         # support passing a Cnf instance
362         if len(args) == 1 and not kwargs:
363             cnf = args[0]
364             assert isinstance(cnf, Cnf), "A Cnf instance is mandatory with one argument"
365         else:
366             cnf = Cnf(*args, **kwargs)
367
368         # store a reference to parent to easily access it
369         cnf.parent = self
370
371         # It seems the CNF backend (at least using set_cnf as opposed to the varlink
372         # API) only accepts instance with value of -1 for top-level variables, so
373         # just in case fix up instances when adding children with the default value.
374         if cnf.instance == -1:
375             cnf.instance = 0
376             for c in self.children:
377                 if c.name == cnf.name:
378                     cnf.instance += 1
379
380         self.children.append(cnf)
381         return cnf
382
383     def add_children(self, *children):
384         """
385         Add multiple child CNF variables.
386
387         Each argument must be either an instance of the :py:class:`Cnf` class
388         or a tuple/list to be expanded and passed to construct that instance.
389
390         :returns: a list of the instances that were created
391         :rtype: :py:class:`CnfList`
392
393         Example::
394             cnf = Cnf("my_parent_cnf", "parent")
395             cnf2 = Cnf("my_child_cnf", "john")
396
397             cnf.add_children(
398                 cnf2,                                  # cnf instance directly
399                 ("my_child_cnf", "jane", instance=2),  # pass a tuple with args
400                 ["my_child_cnf", "jack", instance=3])  # pass a list with args
401
402             # adding a child passing arguments of the Cnf constructor
403             cnf.add_child("my_child_cnf", "jane", instance=2)
404         """
405         added_children = CnfList()
406         for c in children:
407             if isinstance(c, Cnf):
408                 new_child = self.add_child(c)
409             elif isinstance(c, tuple) or isinstance(c, list):
410                 new_child = self.add_child(*c)
411             else:
412                 raise ValueError(f"Children item {c} must be either a Cnf, a tuple or a list")
413             added_children.append(new_child)
414         return added_children
415
416     def __eq__(self, other):
417         """
418         Equality implementation.
419
420         :param other: object to compare this instance against
421         :type other: any
422         :returns: whether `other` is equal to this instance
423         :rtype: bool
424
425         This is particularly useful when comparing instances of
426         :py:class:`CnfList`
427         """
428         if not isinstance(other, Cnf):
429             return False
430
431         # NOTE: we try to define two variables as equal in the same way as the
432         # set_cnf binary would if we were passing it an updated CNF variable.
433         # It does not take comments, children and lineno into account when we
434         # pass it a variable; it will rather compare the data we compare here,
435         # and if it finds a match it will update it with the changed children.
436         return self.name == other.name \
437             and self.value == other.value \
438             and self.instance == other.instance \
439             and self.parent == other.parent
440
441     def __str__(self):
442         """
443         Get a string representation of this instance.
444
445         :returns: a string in the cnfvar format
446         :rtype: str
447         """
448         if self.parent is None:
449             this_str = self._PARENT_TEMPLATE.format(
450                 lineno=self.lineno,
451                 name=self.name.upper(),
452                 instance=self.instance,
453                 value=self.value
454             )
455         else:
456             depth = 0
457             curr = self
458             while curr.parent is not None:
459                 depth += 1
460                 curr = curr.parent
461
462             this_str = self._CHILD_TEMPLATE.format(
463                 lineno=self.lineno,
464                 indent=self._NEST_INDENT * depth,
465                 parent=self.parent.lineno,
466                 name=self.name.upper(),
467                 instance=self.instance,
468                 value=self.value
469             )
470
471         if self.comment is not None:
472             this_str += f" # {self.comment}"
473
474         for child in self.children:
475             this_str += f"\n{child}"
476
477         return this_str
478
479     def __repr__(self):
480         """
481         Get a printable representation of this instance.
482
483         :returns: a string in the cnfvar format
484         :rtype: str
485         """
486         repr_ = self._PARENT_TEMPLATE.format(
487             lineno=self.lineno,
488             name=self.name.upper(),
489             instance=self.instance,
490             value=self.value
491         ) if self.parent is None else self._CHILD_TEMPLATE.format(
492             lineno=self.lineno,
493             indent="",
494             parent=self.parent.lineno,
495             name=self.name.upper(),
496             instance=self.instance,
497             value=self.value
498         )
499         return f"Cnf{{ {repr_} [children={len(self.children)}] }}"
500
501
502 ###############################################################################
503 # MIXINS
504 ###############################################################################
505 #
506 # These mixins add functionality to the base API without polluting it.
507 #
508
509 class CnfListSerializationMixin(BaseCnfList):
510     """Add serialization support to BaseCnfList."""
511
512     def to_cnf_string(self, renumber=True):
513         """
514         Generate a string representation of this list in the cnfvar format.
515
516         :param bool renumber: whether to fix the lineno of the cnfvars
517         :returns: the CNF string
518         :rtype: str
519         """
520         if renumber:
521             self.renumber()
522         return str(self)
523
524     def to_cnf_file(self, path, renumber=True, encoding=ENCODING):
525         """
526         Dump a string representation of this list in the cnfvar format to a file.
527
528         :param str path: path to the file to write to
529         :param bool renumber: whether to fix the lineno of the cnfvars
530         :param str encoding: encoding to use to save the file
531         """
532         if renumber:
533             self.renumber()
534         with open(path, "w", encoding=encoding) as fp:
535             fp.write(str(self))
536
537     def to_json_string(self, renumber=True):
538         """
539         Generate a JSON representation of this list in the cnfvar format.
540
541         :param bool renumber: whether to fix the lineno of the cnfvars
542         :returns: the JSON string
543         :rtype: str
544         """
545         def _to_dict(cnf):
546             d = {
547                 "number": cnf.lineno,
548                 "varname": cnf.name,
549                 "data": cnf.value,
550                 "instance": cnf.instance
551             }
552             if cnf.parent and cnf.parent.lineno:
553                 d["parent"] = cnf.parent.lineno
554             if cnf.comment is not None:
555                 d["comment"] = cnf.comment
556             if len(cnf.children) > 0:
557                 d["children"] = [_to_dict(c) for c in cnf.children]
558             return d
559         if renumber:
560             self.renumber()
561         json_list = [_to_dict(c) for c in self]
562         return json.dumps({"cnf": json_list})
563
564     def to_json_file(self, path, renumber=True):
565         """
566         Dump a JSON representation of this list to a file.
567
568         :param str path: path to the file to write to
569         :param bool renumber: whether to fix the lineno of the cnfvars
570         """
571         with open(path, "w", encoding="utf8") as fp:
572             fp.write(self.to_json_string(renumber=renumber))
573
574     @classmethod
575     def _from_cnf_structure(cls, obj):
576         """
577         Create a list from a JSON structure obtainable from `get_cnf --json`.
578
579         :param obj: an object as defined in the :py:mod:`cnfvar`
580         :type obj: {str, {str, str or int}}
581         :returns: a list of cnfvars
582         :rtype: :py:class:`CnfList`
583         """
584         return cls(map(Cnf._from_cnf_structure, obj["cnf"]))
585
586     @classmethod
587     def from_cnf_string(cls, data):
588         """
589         Create a list from a cnfvar string.
590
591         :param str data: string to generate the list from
592         :returns: a list of cnfvars
593         :rtype: :py:class:`CnfList`
594         """
595         cnf_obj = string.read_cnf(data)
596         return CnfList._from_cnf_structure(cnf_obj)
597
598     @classmethod
599     def from_cnf_file(cls, path, encoding=ENCODING):
600         """
601         Create a list from a cnfvar file.
602
603         :param str path: path to the file to read
604         :param str encoding: encoding to use to open the file (defaults to
605                              latin1 as this is the default encoding)
606         :returns: a list of cnfvars
607         :rtype: :py:class:`CnfList`
608         """
609         with open(path, "r", encoding=encoding) as fp:
610             return CnfList.from_cnf_string(fp.read())
611
612     @classmethod
613     def from_json_string(cls, data):
614         """
615         Create a list from a json string.
616
617         :param str data: string to generate the list from
618         :returns: a list of cnfvars
619         :rtype: :py:class:`CnfList`
620         """
621         cnf_obj = json.loads(data)
622         return CnfList._from_cnf_structure(cnf_obj)
623
624     @classmethod
625     def from_json_file(cls, path):
626         """
627         Create a list from a json file.
628
629         :param str path: path to the file to read
630         :returns: a list of cnfvars
631         :rtype: :py:class:`CnfList`
632         """
633         with open(path, "r", encoding="utf8") as fp:
634             return CnfList.from_json_string(fp.read())
635
636
637 class CnfSerializationMixin(BaseCnf):
638     """Add serialization support to BaseCnf."""
639
640     def to_cnf_string(self, renumber=True):
641         """
642         Generate a string representation of this list in the cnfvar format.
643
644         :param bool renumber: whether to fix the lineno of this cnfvar and its children
645         :returns: the CNF string
646         :rtype: str
647         """
648         return CnfList([self]).to_cnf_string(renumber=renumber)
649
650     def to_cnf_file(self, path, renumber=True, encoding=ENCODING):
651         """
652         Dump a string representation of this instance to a file.
653
654         :param str path: path to the file to write to
655         :param bool renumber: whether to fix the lineno of this cnfvar and its children
656         :param str encoding: encoding to use to save the file
657         """
658         CnfList([self]).to_cnf_file(path, renumber=renumber, encoding=encoding)
659
660     def to_json_string(self, renumber=True):
661         """
662         Convert this instance to a JSON string.
663
664         :param bool renumber: whether to fix the lineno of the cnfvars
665         :returns: the JSON string
666         :rtype: str
667         """
668         return CnfList([self]).to_json_string(renumber=renumber)
669
670     def to_json_file(self, path, renumber=True):
671         """
672         Dump a JSON representation of this instance to a file.
673
674         :param str path: path to the file to write to
675         :param bool renumber: whether to fix the lineno of this cnfvar and its children
676         """
677         CnfList([self]).to_json_file(path, renumber=renumber)
678
679     @classmethod
680     def _from_cnf_structure(cls, obj):
681         """
682         Create an instance from a JSON structure obtainable from `get_cnf --json`.
683
684         :param obj: dictionary to convert to this instance
685         :type obj: {str, str or int}
686         :returns: the cnf instance created
687         :rtype: :py:class:`Cnf`
688         """
689         cnf = Cnf(obj["varname"], obj["data"],
690                   instance=obj["instance"], lineno=obj["number"],
691                   comment=obj.get("comment", None))
692         for ch_obj in obj.get("children", []):
693             child_cnf = Cnf._from_cnf_structure(ch_obj)
694             cnf.add_child(child_cnf)
695         return cnf
696
697     @classmethod
698     def from_cnf_string(cls, data):
699         """
700         Create an instance of this class from a cnfvar string.
701
702         :param str data: cnfvar string to convert
703         :returns: the cnf instance created
704         :rtype: :py:class:`Cnf`
705         """
706         return CnfListSerializationMixin.from_cnf_string(data).single()
707
708     @classmethod
709     def from_cnf_file(cls, path, encoding=ENCODING):
710         """
711         Create an instance of this class from a cnfvar file.
712
713         :param str path: path to the file to read
714         :param str encoding: encoding to use to read the file
715         :returns: the cnf instance created
716         :rtype: :py:class:`Cnf`
717         """
718         return CnfListSerializationMixin.from_cnf_file(path, encoding=encoding).single()
719
720     @classmethod
721     def from_json_string(cls, data):
722         """
723         Create an instance of this class from a JSON string.
724
725         :param str data: JSON string to convert
726         :returns: the cnf instance created
727         :rtype: :py:class:`Cnf`
728         """
729         cnf_obj = json.loads(data)
730         return CnfList._from_cnf_structure(cnf_obj)
731
732     @classmethod
733     def from_json_file(cls, path):
734         """
735         Create an instance of this class from a json file.
736
737         :param str path: path to the file to read
738         :returns: the cnf instance created
739         :rtype: :py:class:`Cnf`
740         """
741         return CnfListSerializationMixin.from_json_file(path).single()
742
743
744 class CnfListArniedApiMixin(BaseCnfList):
745     """Add support for converting this class to and from Arnied API classes."""
746
747     def to_api_structure(self):
748         """
749         Convert this list to the corresponding object in the arnied API.
750
751         :returns: the converted object
752         :rtype: [:py:class:`arnied_api.CnfVar`]
753         """
754         return [c.to_api_structure() for c in self]
755
756     @classmethod
757     def from_api_structure(cls, cnfvar_list):
758         """
759         Convert a list from the arnied API into a list of this type.
760
761         :param cnfvar_list: list to convert
762         :type cnfvar_list: [:py:class:`arnied_api.CnfVar`]
763         :returns: the list created
764         :rtype: :py:class:`CnfList`
765         """
766         return CnfList((Cnf.from_api_structure(c) for c in cnfvar_list),
767                        renumber=True)
768
769
770 class CnfArniedApiMixin(BaseCnf):
771     """Add support for converting this class to and from Arnied API classes."""
772
773     def to_api_structure(self):
774         """
775         Convert this instance to the corresponding object in the arnied API.
776
777         :returns: the converted object
778         :rtype: :py:class:`arnied_api.CnfVar`
779         """
780         return arnied_api.CnfVar(
781             self.name.upper(),
782             self.instance,
783             self.value,
784             False,  # default here to False
785             children=[c.to_api_structure() for c in self.children])
786
787     @classmethod
788     def from_api_structure(cls, cnfobj):
789         """
790         Convert an object from the arnied API into an instance of this type.
791
792         :param cnfobj: object to convert
793         :type cnfobj: :py:class:`arnied_api.CnfVar`
794         :returns: the instance created
795         :rtype: :py:class:`Cnf`
796         """
797         cnf = Cnf(cnfobj.name, cnfobj.data, cnfobj.instance)
798         children = CnfList((Cnf.from_api_structure(c) for c in cnfobj.children))
799         for c in children:
800             c.parent = cnf
801         cnf.children.extend(children)
802         return cnf
803
804
805 class CnfShortcutsMixin(BaseCnf):
806     """Extend the base CNF class with useful methods."""
807
808     def enable(self):
809         """Treat this variable as a boolean var and set its value to 1."""
810         self.value = "1"
811
812     def disable(self):
813         """Treat this variable as a boolean var and set its value to 0."""
814         self.value = "0"
815
816     def is_enabled(self):
817         """Treat this variable as a boolean var and check if its value is 1."""
818         return self.value == "1"
819
820     def enable_child_flag(self, name):
821         """
822         Set the value of the child CNF matching `name` to "1".
823
824         :param str name: name of the child whose value to enable
825
826         .. note:: child will be created if it does not exist.
827         """
828         cnf = self.children.first_with_name(name, default=None)
829         if cnf is None:
830             self.add_child(name, "1")
831         else:
832             cnf.enable()
833
834     def disable_child_flag(self, name):
835         """
836         Set the value of the child CNF matching `name` to "0".
837
838         :param str name: name of the child whose value to disable
839
840         .. note:: child will be created if it does not exist.
841         """
842         cnf = self.children.first_with_name(name, default=None)
843         if cnf is None:
844             self.add_child(name, "0")
845         else:
846             cnf.disable()
847
848     def child_flag_enabled(self, name):
849         """
850         Check if a given child has a value equal to `1`.
851
852         :param str name: name of the child to check
853         :returns: whether the value of the given child, if it exists, is 1
854         :rtype: bool
855         """
856         cnf = self.children.first_with_name(name, default=None)
857         return cnf.is_enabled() if cnf is not None else False
858
859
860 class CnfListQueryingMixin(BaseCnfList):
861     """Mixing adding shortcuts for common filter operations."""
862
863     def single(self, where_filter=None, default=DEFAULT):
864         """
865         Get the only CNF of this list or raise if none or more than one exist.
866
867         :param where_filter: predicate to apply against CNFs beforehand
868         :type where_filter: function accepting a CNF and returning a boolean
869         :param default: value to return in case the list is empty
870         :type default: any
871         :raises: :py:class:`ValueError` if a single value cannot be found and
872                  a default value was not specified
873         :returns: the first and only element of this list, or default if set
874                   and no element is present
875         :rtype: :py:class:`Cnf`
876         """
877         list_ = self.where(where_filter) if where_filter is not None else self
878
879         if len(list_) == 1:
880             return list_[0]
881         elif len(list_) == 0 and default != DEFAULT:
882             return default
883         else:
884             raise ValueError(f"CnfList does not contain a single item (len={len(list_)})")
885
886     def first(self, where_filter=None, default=DEFAULT):
887         """
888         Get the first element in this list or raise if the list is empty.
889
890         :param where_filter: predicate to apply against CNFs beforehand
891         :type where_filter: function accepting a CNF and returning a boolean
892         :param default: value to return in case the list is empty
893         :type default: any
894         :raises: :py:class:`ValueError` if a single value cannot be found and
895                  a default value was not specified
896         :returns: the first element of this list, or default if set and
897                   no element is present
898         :rtype: :py:class:`Cnf`
899         """
900         list_ = self.where(where_filter) if where_filter is not None else self
901         if len(list_) > 0:
902             return list_[0]
903         elif default != DEFAULT:
904             return default
905         else:
906             raise ValueError("Cannot get the first item - CnfList is empty")
907
908     def with_value(self, value):
909         """Shortcut method for filtering by value."""
910         return self.where(lambda c: c.value == value)
911
912     def with_name(self, name):
913         """Shortcut method for filtering by name."""
914         return self.where(lambda c: c.name == name)
915
916     def with_instance(self, instance):
917         """Shortcut method for filtering by instance."""
918         return self.where(lambda c: c.instance == instance)
919
920     def single_with_name(self, name, default=DEFAULT):
921         """Shortcut method for getting the single item with a given name."""
922         return self.with_name(name).single(default=default)
923
924     def single_with_value(self, value, default=DEFAULT):
925         """Shortcut method for getting the single item with a given value."""
926         return self.with_value(value).single(default=default)
927
928     def single_with_instance(self, instance, default=DEFAULT):
929         """Shortcut method for getting the single item with a given instance."""
930         return self.with_instance(instance).single(default=default)
931
932     def first_with_name(self, name, default=DEFAULT):
933         """Shortcut method for getting the first item with a given name."""
934         return self.with_name(name).first(default=default)
935
936     def first_with_value(self, value, default=DEFAULT):
937         """Shortcut method for getting the first item with a given value."""
938         return self.with_value(value).first(default=default)
939
940     def first_with_instance(self, instance, default=DEFAULT):
941         """Shortcut method for getting the first item with a given instance."""
942         return self.with_instance(instance).first(default=default)
943
944     def highest_instance(self):
945         """Shortcut method for getting the next instance in a list of items."""
946         return max([c.instance for c in self]) if len(self) > 0 else -1
947
948
949 class CnfDiff(list):
950     """A list of differences between :py:class:`BaseCnfList`s"""
951
952     def add_missing(self, cnf: BaseCnf, ancestry: Sequence[BaseCnf]):
953         self.append(("-", cnf, ancestry))
954
955     def add_excess(self, cnf: BaseCnf, ancestry: Sequence[BaseCnf]):
956         self.append(("+", cnf, ancestry))
957
958     def print(self, output_func: Callable[[str], Any] = print):
959         """
960         Create a string representation of this diff and "print" it, using given function
961
962         :param output_func: Function to use for printing
963         :return: Iterator over text lines
964         """
965         for diff_type, cnf, ancestry in self:
966             ancestral_string = ' > '.join(f"{anc.name}={anc.value}" for anc in ancestry)
967             output_func(f"cnf diff: {diff_type} {cnf.name} ({cnf.instance}) = {cnf.value!r} in "
968                         + ancestral_string)
969
970
971 class CnfCompareMixin(BaseCnfList):
972     """Mixin to add a `compare()` function."""
973
974     def compare(self, other: BaseCnfList, ignore_list: Sequence[str] = None,
975                 ancestry: Tuple[BaseCnf, ...] = None) -> CnfDiff:
976         """
977         Compare this list of config variables to another list, return differences.
978
979         :param other: another list of configuration variables
980         :param ignore_list: names of variables to ignore
981         :param ancestry: when comparing recursively, we call this function on children (of children ...).
982                          This is the "path" of parent cnf vars that lead to the list of children we
983                          currently compare.
984         :return: difference between self and other
985         """
986         if ancestry is None:
987             ancestry = ()
988         if ignore_list is None:
989             ignore_list = ()
990         diff = CnfDiff()
991         # check whether all own values also appear in other config
992         for c_own in self:
993             c_own = type_hints_pseudo_cast(BaseCnf, c_own)
994             if c_own.name in ignore_list:
995                 continue
996             c_other = other.where(lambda c: c_own == c)
997             if len(c_other) == 0:
998                 diff.add_missing(c_own, ancestry)
999             elif len(c_other) == 1:
1000                 # found matching entry. Recurse into children
1001                 diff.extend(c_own.children.compare(c_other[0].children, ignore_list,
1002                                                    ancestry + (c_own, )))
1003             else:
1004                 # several variables in other have the same name, value, parent, and instance as c_own?!
1005                 raise NotImplementedError("This should not be possible!")
1006
1007         # reverse check: all other values also appear in own config?
1008         for c_other in other:
1009             c_other = type_hints_pseudo_cast(BaseCnf, c_other)
1010             if c_other.name in ignore_list:
1011                 continue
1012             c_own = self.where(lambda c: c_other == c)
1013             if len(c_own) == 0:
1014                 diff.add_excess(c_other, ancestry)
1015             elif len(c_own) == 1:
1016                 pass   # no need to descend into children again
1017             else:
1018                 # several variables in self have the same name, value, parent, and instance as c_other?!
1019                 raise NotImplementedError("This should not be possible!")
1020         return diff
1021
1022 ###############################################################################
1023 # PUBLIC CLASSES
1024 ###############################################################################
1025 #
1026 # Set up the classes with the mixins we want to be available by default.
1027 #
1028
1029
1030 class CnfList(CnfListSerializationMixin, CnfListArniedApiMixin, CnfListQueryingMixin, CnfCompareMixin):
1031     """Collection of Cnf variables."""
1032
1033     pass
1034
1035
1036 class Cnf(CnfSerializationMixin, CnfArniedApiMixin, CnfShortcutsMixin):
1037     """Class representing a cnfvar."""
1038
1039     pass
1040
1041
1042 __all__ = ["CnfList", "Cnf", "CnfDiff"]