Commit | Line | Data |
---|---|---|
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 | """ | |
22 | model: Cnf classes, collection of Cnf classes and multiple filtering methods. | |
23 | ||
d5d2c1d7 CH |
24 | Featuring: |
25 | - Cnf: class representing a CNF variable | |
26 | - CnfList: a collection of `Cnf` instances | |
d31714a0 SA |
27 | |
28 | The classes above inherit from their base types with added mixins which | |
29 | extend 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 | ||
36 | import json | |
b621d44e | 37 | from typing import Sequence, Callable, Any, Tuple, cast as type_hints_pseudo_cast |
d31714a0 | 38 | |
b0075fa2 PD |
39 | from . import string |
40 | from .. import arnied_api | |
d31714a0 SA |
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 | ||
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 | ||
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_) | |
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 | |
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 | ||
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 | ||
509 | class 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 | ||
637 | class 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 | ||
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: | |
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 | ||
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 | ||
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 |
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 | ||
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 | 1030 | class CnfList(CnfListSerializationMixin, CnfListArniedApiMixin, CnfListQueryingMixin, CnfCompareMixin): |
d31714a0 SA |
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 | ||
b621d44e | 1042 | __all__ = ["CnfList", "Cnf", "CnfDiff"] |