1 # The software in this package is distributed under the GNU General
2 # Public License version 2 (with a special exception described below).
4 # A copy of GNU General Public License (GPL) is included in this distribution,
5 # in the file COPYING.GPL.
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.
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.
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.
19 # Copyright (c) 2016-2022 Intra2net AG <info@intra2net.com>
22 model: Cnf classes, collection of Cnf classes and multiple filtering methods.
25 - Cnf: class representing a CNF variable
26 - CnfList: a collection of `Cnf` instances
28 The classes above inherit from their base types with added mixins which
29 extend them with extra functionality.
31 .. seealso:: Overview Diagram linked to from doc main page
33 .. codeauthor:: Intra2net
38 from .. import cnfvar_old, arnied_api
40 #: value used to detect unspecified arguments
42 #: encoding used by the get_cnf and set_cnf binaries
46 ###############################################################################
48 ###############################################################################
53 Custom string where comparisons are case-insensitive.
55 With this class we do not have to worry about case when comparing against
56 the name of cnfvars when filtering. The cnfvar backend is already case-
60 def __eq__(self, other):
61 if not isinstance(other, str):
63 return self.lower() == other.lower()
65 def __contains__(self, name):
66 return name.lower() in self.lower()
68 def startswith(self, prefix, *args, **kwargs):
69 return self.lower().startswith(prefix.lower(), *args, **kwargs)
71 def endswith(self, prefix, *args, **kwargs):
72 return self.lower().endswith(prefix.lower(), *args, **kwargs)
75 ###############################################################################
77 ###############################################################################
80 class BaseCnfList(list):
81 """Base class representing a CNF list with minimal functionality."""
83 def __init__(self, cnf_iter=None, renumber=False):
87 :param cnf_iter: iterator producing CNF elements or arguments for the
88 constructor of the :py:class:`Cnf` class
89 :type: :py:class:`collections.abc.Iterator` producing elements of type
91 :param bool renumber: whether to fix up the number/ids of the CNFs
94 cnf = Cnf("my_cnf", "value")
97 ("other_cnf", "other value"),
98 ("user", "john", instance=3)
101 # Map the values of the iterator to support constructing this list
102 # from Cnf instances or arguments to the Cnf constructor
103 if cnf_iter is not None:
104 iter_ = map(lambda c: c if isinstance(c, Cnf) else Cnf(*c), cnf_iter)
107 super().__init__(iter_)
108 self._renumber_counter = None # initialized and used in renumber
113 """Fix line numbers of CNF variables from this list."""
114 # NOTE: we don't keep track of operations that change the list as this
115 # would require us to reimplement most of the methods. At least for now
116 # this method should be called again when serializing.
117 self._renumber_counter = 0
119 def renumber_fn(cnf):
120 self._renumber_counter += 1
121 cnf.lineno = self._renumber_counter
123 self.for_each_all(renumber_fn)
125 def where(self, where_filter):
127 Filter CNFs matching a given predicate.
129 :param where_filter: predicate to apply against CNFs
130 :type where_filter: function accepting a CNF and returning a boolean
131 :returns: an instance of this class with filtered members
132 :rtype: :py:class:`CnfList`
134 return CnfList(c for c in self if where_filter(c))
136 def where_child(self, where_filter):
138 Filter CNFs with children matching a given predicate.
140 :param where_filter: predicate to apply against children
141 :type where_filter: function accepting a CNF and returning a boolean
142 :returns: an instance of this class with filtered members
143 :rtype: :py:class:`CnfList`
145 def upper_filter(cnf):
146 return any(ch for ch in cnf.children if where_filter(ch))
147 return self.where(upper_filter)
149 def remove_where(self, where_filter):
151 Remove all CNFs from this list matching the given predicate.
153 :param where_filter: predicate to apply against children
154 :type where_filter: function accepting a CNF and returning a boolean
155 :returns: a list of the removed CNF variables
156 :rtype: [:py:class:`Cnf`]
159 # iterate by index for speed and in reverse to keep indexes valid
160 for i in range(len(self) - 1, -1, -1):
162 if where_filter(cnf):
167 def for_each(self, fn):
169 Apply a function to each element of this list.
171 :param fn: function to apply to the elements
172 :type fn: function accepting a CNF (result value is ignored)
173 :returns: this same instance
174 :rtype: :py:class:`CnfList`
176 .. note:: this is mostly the same as the built-in map() function,
177 except that it changes the list in place.
182 except StopIteration:
187 def for_each_child(self, fn):
189 Apply a function to each child of the elements of this list.
191 :param fn: function to apply to the elements
192 :type fn: function accepting a CNF (result value is ignored)
193 :returns: this same instance
194 :rtype: :py:class:`CnfList`
196 .. note:: if a CNF does not have children, it is ignored
199 children = c.children or CnfList()
203 except StopIteration:
206 # apply recursively, too
207 children.for_each_child(fn)
210 def for_each_all(self, fn):
212 Apply a function to every CNF of this list, parent or child.
214 :param fn: function to apply to the elements
215 :type fn: function accepting a CNF (result value is ignored)
216 :returns: this same instance
217 :rtype: :py:class:`CnfList`
222 except StopIteration:
225 children = c.children or CnfList()
226 children.for_each_all(fn)
231 Get a string representation of this instance.
233 :returns: a string in the cnfvar format
236 return "\n".join((str(c) for c in self))
238 def __add__(self, other):
239 return CnfList(super().__add__(other))
243 """Base class representing a CNF variable with minimal functionality."""
245 _PARENT_TEMPLATE = "{lineno} {name},{instance}: \"{value}\""
246 _CHILD_TEMPLATE = "{lineno} {indent}({parent}) {name},{instance}: \"{value}\""
249 def __init__(self, name, value, instance=0, parent=None,
250 lineno=None, comment=None):
252 Create this instance.
254 :param str name: name of the cnfvar (case does not matter)
255 :param str value: value for this cnfvar (will be converted to string
256 if it is not of this type)
257 :param int instance: instance of this cnfvar
258 :param parent: a parent Cnf instance
259 :type parent: :py:class:`BaseCnf`
260 :param int lineno: line number
261 :param str comment: cnfvar comment
263 self.name = CnfName(name)
265 self.instance = int(instance)
267 self.lineno = int(lineno or 0)
268 self.comment = comment
269 self.__children = CnfList()
271 # Use getters and setters to keep internal consistency and fail-fast
272 # preventing invalid data from being sent to the cnfvar backend.
277 def _set_name(self, value):
278 # convert Python strings passed as name to our custom string
279 self.__name = CnfName(value)
280 name = property(_get_name, _set_name)
282 def _get_instance(self):
283 return self.__instance
285 def _set_instance(self, value):
286 # fail-fast and make sure instance is a valid integer
287 self.__instance = int(value)
288 instance = property(_get_instance, _set_instance)
290 def _get_lineno(self):
293 def _set_lineno(self, value):
294 # fail-fast and make sure lineno is a valid integer
295 self.__lineno = int(value)
296 lineno = property(_get_lineno, _set_lineno)
298 def _get_children(self):
299 return self.__children
300 # No setter to sure that the children property will not
301 # be replaced by something other than a `CnfList`
302 children = property(_get_children)
304 def _get_value(self):
307 def _set_value(self, value):
308 # Make sure the value is always stored as a string
309 # (no other types make sense to the cnfvar backend)
310 self.__value = str(value)
311 value = property(_get_value, _set_value)
313 def add_child(self, *args, **kwargs):
315 Add a child CNF variable.
317 Arguments can either be a single instance of the :py:class:`Cnf`
318 class or a list of arguments to be passed to the constructor of
321 :returns: the instance that was created
322 :rtype: :py:class:`Cnf`
326 cnf = Cnf("my_parent_cnf", "parent")
327 cnf2 = Cnf("my_child_cnf", "john")
329 # adding a child as a CNF instance
332 # adding a child passing arguments of the Cnf constructor
333 cnf.add_child("my_child_cnf", "jane", instance=2)
335 # support passing a Cnf instance
336 if len(args) == 1 and not kwargs:
338 assert isinstance(cnf, Cnf), \
339 "With one argument, a Cnf instance is mandatory"
341 cnf = Cnf(*args, **kwargs)
343 # store a reference to parent to easily access it
346 # It seems the CNF backend (at least using set_cnf as opposed to the varlink
347 # API) only accepts instance with value of -1 for top-level variables, so
348 # just in case fix up instances when adding children with the default value.
349 if cnf.instance == -1:
351 for c in self.children:
352 if c.name == cnf.name:
355 self.children.append(cnf)
358 def add_children(self, *children):
360 Add multiple child CNF variables.
362 Each argument must be either an instance of the :py:class:`Cnf` class
363 or a tuple/list to be expanded and passed to construct that instance.
365 :returns: a list of the instances that were created
366 :rtype: :py:class:`CnfList`
369 cnf = Cnf("my_parent_cnf", "parent")
370 cnf2 = Cnf("my_child_cnf", "john")
373 cnf2, # cnf instance directly
374 ("my_child_cnf", "jane", instance=2), # pass a tuple with args
375 ["my_child_cnf", "jack", instance=3]) # pass a list with args
377 # adding a child passing arguments of the Cnf constructor
378 cnf.add_child("my_child_cnf", "jane", instance=2)
380 added_children = CnfList()
382 if isinstance(c, Cnf):
383 new_child = self.add_child(c)
384 elif isinstance(c, tuple) or isinstance(c, list):
385 new_child = self.add_child(*c)
387 raise ValueError(f"Children item {c} must be either a Cnf, a tuple or a list")
388 added_children.append(new_child)
389 return added_children
391 def __eq__(self, other):
393 Equality implementation.
395 :param other: object to compare this instance against
397 :returns: whether `other` is equal to this instance
400 This is particularly useful when comparing instances of
403 if not isinstance(other, Cnf):
406 # NOTE: we try to define two variables as equal in the same way as the
407 # set_cnf binary would if we were passing it an updated CNF variable.
408 # It does not take comments, children and lineno into account when we
409 # pass it a variable; it will rather compare the data we compare here,
410 # and if it finds a match it will update it with the changed children.
411 return self.name == other.name \
412 and self.value == other.value \
413 and self.instance == other.instance \
414 and self.parent == other.parent
418 Get a string representation of this instance.
420 :returns: a string in the cnfvar format
423 if self.parent is None:
424 this_str = self._PARENT_TEMPLATE.format(
426 name=self.name.upper(),
427 instance=self.instance,
433 while curr.parent is not None:
437 this_str = self._CHILD_TEMPLATE.format(
439 indent=self._NEST_INDENT * depth,
440 parent=self.parent.lineno,
441 name=self.name.upper(),
442 instance=self.instance,
446 if self.comment is not None:
447 this_str += f" # {self.comment}"
449 for child in self.children:
450 this_str += f"\n{child}"
456 Get a printable representation of this instance.
458 :returns: a string in the cnfvar format
461 repr_ = self._PARENT_TEMPLATE.format(
463 name=self.name.upper(),
464 instance=self.instance,
466 ) if self.parent is None else self._CHILD_TEMPLATE.format(
469 parent=self.parent.lineno,
470 name=self.name.upper(),
471 instance=self.instance,
474 return f"Cnf{{ {repr_} [children={len(self.children)}] }}"
477 ###############################################################################
479 ###############################################################################
481 # These mixins add functionality to the base API without polluting it.
484 class CnfListSerializationMixin(BaseCnfList):
485 """Add serialization support to BaseCnfList."""
487 def to_cnf_structure(self, renumber=True):
489 Convert this list to an object meaningful to :py:mod:`cnfvar`.
491 :param bool renumber: whether to fix up the number/ids of the CNFs
492 :returns: a dictionary with the converted values
493 :rtype: {str, {str, str or int}}
497 return {"cnf": [x.to_cnfvar_dict() for x in self]}
499 def to_cnf_file(self, path, renumber=True, encoding=ENCODING):
501 Dump a string representation of this list in the cnfvar format to a file.
503 :param str path: path to the file to write to
504 :param bool renumber: whether to fix the lineno of the cnfvars
505 :param str encoding: encoding to use to save the file
509 with open(path, "w", encoding=encoding) as fp:
512 def to_json_string(self, renumber=True):
514 Generate a JSON representation of this list in the cnfvar format.
516 :param bool renumber: whether to fix the lineno of the cnfvars
517 :returns: the JSON string
522 "number": cnf.lineno,
525 "instance": cnf.instance
527 if cnf.parent and cnf.parent.lineno:
528 d["parent"] = cnf.parent.lineno
529 if cnf.comment is not None:
530 d["comment"] = cnf.comment
531 if len(cnf.children) > 0:
532 d["children"] = [_to_dict(c) for c in cnf.children]
536 json_list = [_to_dict(c) for c in self]
537 return json.dumps({"cnf": json_list})
539 def to_json_file(self, path, renumber=True):
541 Dump a JSON representation of this list to a file.
543 :param str path: path to the file to write to
544 :param bool renumber: whether to fix the lineno of the cnfvars
546 with open(path, "w", encoding="utf8") as fp:
547 fp.write(self.to_json_string(renumber=renumber))
550 def from_cnf_structure(cls, obj):
552 Create a list from a cnfvar object from the :py:mod:`cnfvar` module.
554 :param obj: an object as defined in the :py:mod:`cnfvar`
555 :type obj: {str, {str, str or int}}
556 :returns: a list of cnfvars
557 :rtype: :py:class:`CnfList`
559 return cls(map(Cnf.from_cnf_structure, obj["cnf"]))
562 def from_cnf_string(cls, data):
564 Create a list from a cnfvar string.
566 :param str data: string to generate the list from
567 :returns: a list of cnfvars
568 :rtype: :py:class:`CnfList`
570 cnf_obj = cnfvar_old.read_cnf(data)
571 return CnfList.from_cnf_structure(cnf_obj)
574 def from_json_string(cls, data):
576 Create a list from a json string.
578 :param str data: string to generate the list from
579 :returns: a list of cnfvars
580 :rtype: :py:class:`CnfList`
582 cnf_obj = json.loads(data)
583 return CnfList.from_cnf_structure(cnf_obj)
586 def from_cnf_file(cls, path, encoding=ENCODING):
588 Create a list from a cnfvar file.
590 :param str path: path to the file to read
591 :param str encoding: encoding to use to open the file (defaults to
592 latin1 as this is the default encoding)
593 :returns: a list of cnfvars
594 :rtype: :py:class:`CnfList`
596 with open(path, "r", encoding=encoding) as fp:
597 return CnfList.from_cnf_string(fp.read())
600 def from_json_file(cls, path):
602 Create a list from a json file.
604 :param str path: path to the file to read
605 :returns: a list of cnfvars
606 :rtype: :py:class:`CnfList`
608 with open(path, "r", encoding="utf8") as fp:
609 return CnfList.from_json_string(fp.read())
612 class CnfSerializationMixin(BaseCnf):
613 """Add serialization support to BaseCnf."""
615 def to_cnfvar_dict(self):
617 Convert this instance to dictionary from the :py:mod:`cnfvar` module.
619 :returns: the dictionary created
620 :rtype: {str, str or int}
622 .. todo:: this method is still needed because dumping cnf variables
623 to strings (json or not) is still delegated to the old cnfvar module.
626 "number": self.lineno,
627 "varname": self.name,
629 "instance": self.instance
631 if self.parent and self.parent.lineno:
632 d["parent"] = self.parent.lineno
633 if self.comment is not None:
634 d["comment"] = self.comment
635 if len(self.children) > 0:
636 d["children"] = [c.to_cnfvar_dict() for c in self.children]
639 def to_json_string(self, renumber=True):
641 Convert this instance to a JSON string.
643 :param bool renumber: whether to fix the lineno of the cnfvars
644 :returns: the JSON string
647 return CnfList([self]).to_json_string(renumber=renumber)
649 def to_cnf_file(self, path, renumber=True, encoding=ENCODING):
651 Dump a string representation of this instance to a file.
653 :param str path: path to the file to write to
654 :param bool renumber: whether to fix the lineno of this cnfvar and its children
655 :param str encoding: encoding to use to save the file
657 CnfList([self]).to_cnf_file(path, renumber=renumber, encoding=encoding)
659 def to_json_file(self, path, renumber=True):
661 Dump a JSON representation of this instance to a file.
663 :param str path: path to the file to write to
664 :param bool renumber: whether to fix the lineno of this cnfvar and its children
666 CnfList([self]).to_json_file(path, renumber=renumber)
669 def from_cnf_structure(cls, obj):
671 Create an instance from a dictionary from the :py:mod:`cnfvar` module.
673 :param obj: dictionary to convert to this instance
674 :type obj: {str, str or int}
675 :returns: the cnf instance created
676 :rtype: :py:class:`Cnf`
678 cnf = Cnf(obj["varname"], obj["data"],
679 instance=obj["instance"], lineno=obj["number"],
680 comment=obj.get("comment", None))
681 for ch_obj in obj.get("children", []):
682 child_cnf = Cnf.from_cnf_structure(ch_obj)
683 cnf.add_child(child_cnf)
687 def from_cnf_string(cls, data):
689 Create an instance of this class from a cnfvar string.
691 :param str data: cnfvar string to convert
692 :returns: the cnf instance created
693 :rtype: :py:class:`Cnf`
695 return CnfListSerializationMixin.from_cnf_string(data).single()
698 def from_json_string(cls, data):
700 Create an instance of this class from a JSON string.
702 :param str data: JSON string to convert
703 :returns: the cnf instance created
704 :rtype: :py:class:`Cnf`
706 return CnfListSerializationMixin.from_json_string(data).single()
709 def from_cnf_file(cls, path, encoding=ENCODING):
711 Create an instance of this class from a cnfvar file.
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`
718 return CnfListSerializationMixin.from_cnf_file(path, encoding=encoding).single()
721 def from_json_file(cls, path):
723 Create an instance of this class from a json file.
725 :param str path: path to the file to read
726 :returns: the cnf instance created
727 :rtype: :py:class:`Cnf`
729 return CnfListSerializationMixin.from_json_file(path).single()
732 class CnfListArniedApiMixin(BaseCnfList):
733 """Add support for converting this class to and from Arnied API classes."""
735 def to_api_structure(self):
737 Convert this list to the corresponding object in the arnied API.
739 :returns: the converted object
740 :rtype: [:py:class:`arnied_api.CnfVar`]
742 return [c.to_api_structure() for c in self]
745 def from_api_structure(cls, cnfvar_list):
747 Convert a list from the arnied API into a list of this type.
749 :param cnfvar_list: list to convert
750 :type cnfvar_list: [:py:class:`arnied_api.CnfVar`]
751 :returns: the list created
752 :rtype: :py:class:`CnfList`
754 return CnfList((Cnf.from_api_structure(c) for c in cnfvar_list),
758 class CnfArniedApiMixin(BaseCnf):
759 """Add support for converting this class to and from Arnied API classes."""
761 def to_api_structure(self):
763 Convert this instance to the corresponding object in the arnied API.
765 :returns: the converted object
766 :rtype: :py:class:`arnied_api.CnfVar`
768 return arnied_api.CnfVar(
772 False, # default here to False
773 children=[c.to_api_structure() for c in self.children])
776 def from_api_structure(cls, cnfobj):
778 Convert an object from the arnied API into an instance of this type.
780 :param cnfobj: object to convert
781 :type cnfobj: :py:class:`arnied_api.CnfVar`
782 :returns: the instance created
783 :rtype: :py:class:`Cnf`
785 cnf = Cnf(cnfobj.name, cnfobj.data, cnfobj.instance)
786 children = CnfList((Cnf.from_api_structure(c) for c in cnfobj.children))
789 cnf.children.extend(children)
793 class CnfShortcutsMixin(BaseCnf):
794 """Extend the base CNF class with useful methods."""
797 """Treat this variable as a boolean var and set its value to 1."""
801 """Treat this variable as a boolean var and set its value to 0."""
804 def is_enabled(self):
805 """Treat this variable as a boolean var and check if its value is 1."""
806 return self.value == "1"
808 def enable_child_flag(self, name):
810 Set the value of the child CNF matching `name` to "1".
812 :param str name: name of the child whose value to enable
814 .. note:: child will be created if it does not exist.
816 cnf = self.children.first_with_name(name, default=None)
818 self.add_child(name, "1")
822 def disable_child_flag(self, name):
824 Set the value of the child CNF matching `name` to "0".
826 :param str name: name of the child whose value to disable
828 .. note:: child will be created if it does not exist.
830 cnf = self.children.first_with_name(name, default=None)
832 self.add_child(name, "0")
836 def child_flag_enabled(self, name):
838 Check if a given child has a value equal to `1`.
840 :param str name: name of the child to check
841 :returns: whether the value of the given child, if it exists, is 1
844 cnf = self.children.first_with_name(name, default=None)
845 return cnf.is_enabled() if cnf is not None else False
848 class CnfListQueryingMixin(BaseCnfList):
849 """Mixing adding shortcuts for common filter operations."""
851 def single(self, where_filter=None, default=DEFAULT):
853 Get the only CNF of this list or raise if none or more than one exist.
855 :param where_filter: predicate to apply against CNFs beforehand
856 :type where_filter: function accepting a CNF and returning a boolean
857 :param default: value to return in case the list is empty
859 :raises: :py:class:`ValueError` if a single value cannot be found and
860 a default value was not specified
861 :returns: the first and only element of this list, or default if set
862 and no element is present
863 :rtype: :py:class:`Cnf`
865 list_ = self.where(where_filter) if where_filter is not None else self
869 elif len(list_) == 0 and default != DEFAULT:
872 raise ValueError(f"CnfList does not contain a single item (len={len(list_)})")
874 def first(self, where_filter=None, default=DEFAULT):
876 Get the first element in this list or raise if the list is empty.
878 :param where_filter: predicate to apply against CNFs beforehand
879 :type where_filter: function accepting a CNF and returning a boolean
880 :param default: value to return in case the list is empty
882 :raises: :py:class:`ValueError` if a single value cannot be found and
883 a default value was not specified
884 :returns: the first element of this list, or default if set and
885 no element is present
886 :rtype: :py:class:`Cnf`
888 list_ = self.where(where_filter) if where_filter is not None else self
891 elif default != DEFAULT:
894 raise ValueError("Cannot get the first item - CnfList is empty")
896 def with_value(self, value):
897 """Shortcut method for filtering by value."""
898 return self.where(lambda c: c.value == value)
900 def with_name(self, name):
901 """Shortcut method for filtering by name."""
902 return self.where(lambda c: c.name == name)
904 def with_instance(self, instance):
905 """Shortcut method for filtering by instance."""
906 return self.where(lambda c: c.instance == instance)
908 def single_with_name(self, name, default=DEFAULT):
909 """Shortcut method for getting the single item with a given name."""
910 return self.with_name(name).single(default=default)
912 def single_with_value(self, value, default=DEFAULT):
913 """Shortcut method for getting the single item with a given value."""
914 return self.with_value(value).single(default=default)
916 def single_with_instance(self, instance, default=DEFAULT):
917 """Shortcut method for getting the single item with a given instance."""
918 return self.with_instance(instance).single(default=default)
920 def first_with_name(self, name, default=DEFAULT):
921 """Shortcut method for getting the first item with a given name."""
922 return self.with_name(name).first(default=default)
924 def first_with_value(self, value, default=DEFAULT):
925 """Shortcut method for getting the first item with a given value."""
926 return self.with_value(value).first(default=default)
928 def first_with_instance(self, instance, default=DEFAULT):
929 """Shortcut method for getting the first item with a given instance."""
930 return self.with_instance(instance).first(default=default)
933 ###############################################################################
935 ###############################################################################
937 # Set up the classes with the mixins we want to be available by default.
941 class CnfList(CnfListSerializationMixin, CnfListArniedApiMixin, CnfListQueryingMixin):
942 """Collection of Cnf variables."""
947 class Cnf(CnfSerializationMixin, CnfArniedApiMixin, CnfShortcutsMixin):
948 """Class representing a cnfvar."""
953 __all__ = ["CnfList", "Cnf"]