Make CnfVar lists comparable
[pyi2ncommon] / src / cnfvar / model.py
CommitLineData
d31714a0
SA
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"""
22model: Cnf classes, collection of Cnf classes and multiple filtering methods.
23
d5d2c1d7
CH
24Featuring:
25 - Cnf: class representing a CNF variable
26 - CnfList: a collection of `Cnf` instances
d31714a0
SA
27
28The classes above inherit from their base types with added mixins which
29extend them with extra functionality.
30
d5d2c1d7
CH
31.. seealso:: Overview Diagram linked to from doc main page
32
d31714a0
SA
33.. codeauthor:: Intra2net
34"""
35
36import json
b621d44e 37from typing import Sequence, Callable, Any, Tuple, cast as type_hints_pseudo_cast
d31714a0 38
b0075fa2
PD
39from . import string
40from .. import arnied_api
d31714a0
SA
41
42#: value used to detect unspecified arguments
43DEFAULT = object()
44#: encoding used by the get_cnf and set_cnf binaries
45ENCODING = "latin1"
46
47
48###############################################################################
49# HELPERS
50###############################################################################
51
52
53class 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
d5d2c1d7
CH
70 def startswith(self, prefix, *args, **kwargs):
71 return self.lower().startswith(prefix.lower(), *args, **kwargs)
d31714a0 72
6cee0cd4
PD
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)
d31714a0
SA
78
79
80###############################################################################
81# BASE API
82###############################################################################
83
84
85class 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_)
d5d2c1d7 113 self._renumber_counter = None # initialized and used in renumber
d31714a0
SA
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.
d5d2c1d7 122 self._renumber_counter = 0
d31714a0
SA
123
124 def renumber_fn(cnf):
d5d2c1d7
CH
125 self._renumber_counter += 1
126 cnf.lineno = self._renumber_counter
d31714a0
SA
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
d5d2c1d7 136 :returns: an instance of this class with filtered members
d31714a0
SA
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
d5d2c1d7 147 :returns: an instance of this class with filtered members
d31714a0
SA
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
bee1c853
PD
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
d31714a0
SA
267
268class 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
c89cc126 275 def __init__(self, name, value, instance=0, parent=None,
d31714a0
SA
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]
bee1c853 364 assert isinstance(cnf, Cnf), "A Cnf instance is mandatory with one argument"
d31714a0
SA
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
509class CnfListSerializationMixin(BaseCnfList):
510 """Add serialization support to BaseCnfList."""
511
865ff8a9
PD
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
d31714a0
SA
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:
d5d2c1d7 560 self.renumber()
d31714a0
SA
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
37d7c300 575 def _from_cnf_structure(cls, obj):
d31714a0 576 """
37d7c300 577 Create a list from a JSON structure obtainable from `get_cnf --json`.
d31714a0
SA
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 """
37d7c300 584 return cls(map(Cnf._from_cnf_structure, obj["cnf"]))
d31714a0
SA
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 """
b0075fa2 595 cnf_obj = string.read_cnf(data)
37d7c300 596 return CnfList._from_cnf_structure(cnf_obj)
d31714a0
SA
597
598 @classmethod
d31714a0
SA
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
865ff8a9
PD
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
d31714a0
SA
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
637class CnfSerializationMixin(BaseCnf):
638 """Add serialization support to BaseCnf."""
639
865ff8a9 640 def to_cnf_string(self, renumber=True):
d31714a0 641 """
865ff8a9 642 Generate a string representation of this list in the cnfvar format.
d31714a0 643
865ff8a9
PD
644 :param bool renumber: whether to fix the lineno of this cnfvar and its children
645 :returns: the CNF string
d31714a0
SA
646 :rtype: str
647 """
865ff8a9 648 return CnfList([self]).to_cnf_string(renumber=renumber)
d31714a0
SA
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
865ff8a9
PD
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
d31714a0
SA
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
37d7c300 680 def _from_cnf_structure(cls, obj):
d31714a0 681 """
37d7c300 682 Create an instance from a JSON structure obtainable from `get_cnf --json`.
d31714a0
SA
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", []):
37d7c300 693 child_cnf = Cnf._from_cnf_structure(ch_obj)
d31714a0
SA
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
865ff8a9 709 def from_cnf_file(cls, path, encoding=ENCODING):
d31714a0 710 """
865ff8a9 711 Create an instance of this class from a cnfvar file.
d31714a0 712
865ff8a9
PD
713 :param str path: path to the file to read
714 :param str encoding: encoding to use to read the file
d31714a0
SA
715 :returns: the cnf instance created
716 :rtype: :py:class:`Cnf`
717 """
865ff8a9 718 return CnfListSerializationMixin.from_cnf_file(path, encoding=encoding).single()
d31714a0
SA
719
720 @classmethod
865ff8a9 721 def from_json_string(cls, data):
d31714a0 722 """
865ff8a9 723 Create an instance of this class from a JSON string.
d31714a0 724
865ff8a9 725 :param str data: JSON string to convert
d31714a0
SA
726 :returns: the cnf instance created
727 :rtype: :py:class:`Cnf`
728 """
865ff8a9
PD
729 cnf_obj = json.loads(data)
730 return CnfList._from_cnf_structure(cnf_obj)
d31714a0
SA
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
744class 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
770class 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
805class 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:
d5d2c1d7 830 self.add_child(name, "1")
d31714a0
SA
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:
d5d2c1d7 844 self.add_child(name, "0")
d31714a0
SA
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
860class 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
cdfd8f20
PD
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
d31714a0 948
b621d44e
CH
949class 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
971class 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
d31714a0
SA
1022###############################################################################
1023# PUBLIC CLASSES
1024###############################################################################
1025#
1026# Set up the classes with the mixins we want to be available by default.
1027#
1028
1029
b621d44e 1030class CnfList(CnfListSerializationMixin, CnfListArniedApiMixin, CnfListQueryingMixin, CnfCompareMixin):
d31714a0
SA
1031 """Collection of Cnf variables."""
1032
1033 pass
1034
1035
1036class Cnf(CnfSerializationMixin, CnfArniedApiMixin, CnfShortcutsMixin):
1037 """Class representing a cnfvar."""
1038
1039 pass
1040
1041
b621d44e 1042__all__ = ["CnfList", "Cnf", "CnfDiff"]