Various fixes to the cnfvar module
[pyi2ncommon] / src / cnfvar_old.py
1 #!/usr/bin/env python
2 #
3 # The software in this package is distributed under the GNU General
4 # Public License version 2 (with a special exception described below).
5 #
6 # A copy of GNU General Public License (GPL) is included in this distribution,
7 # in the file COPYING.GPL.
8 #
9 # As a special exception, if other files instantiate templates or use macros
10 # or inline functions from this file, or you compile this file and link it
11 # with other works to produce a work based on this file, this file
12 # does not by itself cause the resulting work to be covered
13 # by the GNU General Public License.
14 #
15 # However the source code for this file must still be made available
16 # in accordance with section (3) of the GNU General Public License.
17 #
18 # This exception does not invalidate any other reasons why a work based
19 # on this file might be covered by the GNU General Public License.
20 #
21 # Copyright (c) 2016-2018 Intra2net AG <info@intra2net.com>
22
23 """
24
25 summary
26 -------------------------------------------------------------------------------
27 Represent CNF_VARs as recursive structures.
28
29 Copyright: 2014-2017 Intra2net AG
30 License:   GPLv2+
31
32
33 contents
34 -------------------------------------------------------------------------------
35
36 This module provides read and write functionality for the Intra2net *CNF*
37 format. Two different syntaxes are available: classical *CNF* and a JSON
38 representation. Both versions are commonly understood by Intra2net software.
39
40 On the command line, raw CNF is accepted if the option ``-`` is given: ::
41
42     $ get_cnf routing 2 |python3 cnfvar.py - <<ENOUGH
43     1 ROUTING,2: "192.168.55.0"
44     2    (1) ROUTING_COMMENT,0: ""
45     3    (1) ROUTING_DNS_RELAYING_ALLOWED,0: "1"
46     4    (1) ROUTING_EMAIL_RELAYING_ALLOWED,0: "1"
47     5    (1) ROUTING_FIREWALL_RULESET_REF,0: "9"
48     6    (1) ROUTING_GATEWAY,0: "10.0.254.1"
49     7    (1) ROUTING_NAT_INTO,0: "0"
50     8    (1) ROUTING_NETMASK,0: "255.255.255.0"
51     9    (1) ROUTING_PROXY_PROFILE_REF,0: "2"
52     ENOUGH
53
54     1 ROUTING,2: "192.168.55.0"
55     2   (1) ROUTING_COMMENT,0: ""
56     3   (1) ROUTING_DNS_RELAYING_ALLOWED,0: "1"
57     4   (1) ROUTING_EMAIL_RELAYING_ALLOWED,0: "1"
58     5   (1) ROUTING_FIREWALL_RULESET_REF,0: "9"
59     6   (1) ROUTING_GATEWAY,0: "10.0.254.1"
60     7   (1) ROUTING_NAT_INTO,0: "0"
61     8   (1) ROUTING_NETMASK,0: "255.255.255.0"
62     9   (1) ROUTING_PROXY_PROFILE_REF,0: "2"
63
64 The input takes one round-trip through the parsers and will error out on
65 problematic lines. Thus, ``cnfvar.py`` can be used to syntax-check CNF data.
66
67 Note that line numbers may be arbitrarily reassigned in the process. Of course,
68 parent references and the relative ordering of lines will be preserved in this
69 case.
70
71 .. todo::
72     Decide on some facility for automatic fixup of line number values. The
73     internal representation is recursive so line numbers are not needed to
74     establish a variable hierarchy. They might as well be omitted from the json
75     input and only added when writing cnf. For the time being a lack of the
76     "number" field is interpreted as an error. Though it should at least
77     optionally be possible to omit the numbers entirely and have a function add
78     them after the fact. This might as well be added to :py:func:`is_cnf`
79     though it would be counterintuitive to have a predicate mutate its
80     argument. So maybe it could return a second argument to indicate a valid
81     structure that needs fixup or something like that.
82
83 .. note::
84     The variable values of get_cnf seems to be encoded in latin1, and set_cnf
85     seems to assume latin1-encoded values (not var names). Function
86     :py:func:`read_cnf` converts this to unicode and functions
87     :py:func:`dump_cnf_string`, :py:func:`print_cnf`, and :py:func:`write_cnf`
88     convert unicode back to latin1.
89
90
91 notes on Python 3 conversion
92 -------------------------------------------------------------------------------
93
94 Since the original *CNF* format assumes latin-1 encoded data pretty much
95 exclusively, we preserve the original encoding while parsing the file.
96 When assembling the data structures returned to the user, values are then
97 converted to strings so they can be used naturally at the Python end.
98
99 implementation
100 -------------------------------------------------------------------------------
101 """
102
103 import functools
104 import sys
105 import json
106 import re
107 import io
108
109
110 ###############################################################################
111 # CONSTANTS
112 ###############################################################################
113
114
115 CNF_FIELD_MANDATORY = set ([ "varname", "data", "instance" ])
116 CNF_FIELD_OPTIONAL  = set ([ "parent", "children", "comment", "number" ])
117 CNF_FIELD_KNOWN     = CNF_FIELD_MANDATORY | CNF_FIELD_OPTIONAL
118
119 grab_parent_pattern = re.compile(b"""
120                                     ^            # match from start
121                                     \s*          # optional spaces
122                                     \d+          # line number
123                                     \s+          # spaces
124                                     \((\d+)\)    # parent
125                                  """,
126                                  re.VERBOSE)
127
128 base_line_pattern = re.compile(b"""
129                                     ^                    # match from start
130                                     \s*                  # optional spaces
131                                     (\d+)                # line number
132                                     \s+                  # spaces
133                                     ([A-Z][A-Z0-9_]*)    # varname
134                                     \s*                  # optional spaces
135                                     ,                    # delimiter
136                                     \s*                  # optional spaces
137                                     (-1|\d+)             # instance
138                                     \s*                  # optional spaces
139                                     :                    # delimiter
140                                     \s*                  # optional spaces
141                                     \"(                  # quoted string (data)
142                                        (?:  \\\"         #  (of escaped dquote
143                                            |[^\"])*      #   or anything not a
144                                       )\"                #   literal quote)
145                                     \s*                  # optional spaces
146                                     (                    # bgroup
147                                      \#                  # comment leader
148                                      \s*                 # optional spaces
149                                      .*                  # string (comment)
150                                     )?                   # egroup, optional
151                                     $                    # eol
152                                """,
153                                re.VERBOSE)
154
155 child_line_pattern = re.compile(b"""
156                                      ^                    # match from start
157                                      \s*                  # optional spaces
158                                      (\d+)                # line number
159                                      \s+                  # spaces
160                                      \((\d+)\)            # parent
161                                      \s+                  # spaces
162                                      ([A-Z][A-Z0-9_]*)    # varname
163                                      \s*                  # optional spaces
164                                      ,                    # delimiter
165                                      \s*                  # optional spaces
166                                      (-1|\d+)             # instance
167                                      \s*                  # optional spaces
168                                      :                    # delimiter
169                                      \s*                  # optional spaces
170                                      \"([^\"]*)\"         # quoted string (data)
171                                      \s*                  # optional spaces
172                                      (                    # bgroup
173                                       \#                  # comment leader
174                                       \s*                 # optional spaces
175                                       .*                  # string (comment)
176                                      )?                   # egroup, optional
177                                      $                    # eol
178                                 """,
179                                 re.VERBOSE)
180
181
182 ###############################################################################
183 # HELPERS
184 ###############################################################################
185
186
187 #
188 # Sadly, the Intranator is still stuck with one leg in the 90s.
189 #
190 def to_latin1(s):
191     """Take given unicode str and convert it to a latin1-encoded `bytes`."""
192     return s.encode("latin-1")
193
194
195 def from_latin1(s):
196     """Take given latin1-encoded `bytes` value and convert it to `str`."""
197     return s.decode("latin-1")
198
199
200 #
201 # Conversion functions
202 #
203
204 def marshal_in_number(number):
205     return int(number)
206
207
208 def marshal_in_parent(parent):
209     return int(parent)
210
211
212 def marshal_in_instance(instance):
213     return int(instance)
214
215
216 def marshal_in_varname(varname):
217     return from_latin1(varname).lower()
218
219
220 def marshal_in_data(data):
221     return from_latin1(data) if data is not None else ""
222
223
224 def marshal_in_comment(comment):
225     return comment and from_latin1(comment[1:].strip()) or None
226
227
228 #
229 # Type checking
230 #
231
232 def is_string(s):
233     return isinstance(s, str)
234
235
236 ###############################################################################
237 # EXCEPTIONS
238 ###############################################################################
239
240
241 class InvalidCNF(Exception):
242
243     def __init__(self, msg):
244         self.msg = msg
245
246     def __str__(self):
247         return "Malformed CNF_VAR: \"%s\"" % self.msg
248
249
250 class MalformedCNF(Exception):
251
252     def __init__(self, msg):
253         self.msg = msg
254
255     def __str__(self):
256         return "Malformed CNF file: \"%s\"" % self.msg
257
258
259 ###############################################################################
260 # VALIDATION
261 ###############################################################################
262
263
264 def is_valid(acc,
265              nested,
266              comment,
267              data,
268              instance,
269              number,
270              parent,
271              varname):
272     if varname is None:
273         raise InvalidCNF("CNF_VAR lacks a name.")
274     elif not is_string(varname):
275         raise InvalidCNF("Varname field of CNF_VAR \"%s\" is not a string."
276                          % varname)
277     elif varname == "":
278         raise InvalidCNF("Varname field of CNF_VAR is the empty string.")
279
280     if comment is not None:
281         if not is_string(comment):
282             raise InvalidCNF("Comment field of CNF_VAR \"%s\" is not a string."
283                              % varname)
284
285     if data is None:
286         raise InvalidCNF("Data field of CNF_VAR \"%s\" is empty."
287                          % varname)
288     elif not is_string(data):
289         raise InvalidCNF("Data field of CNF_VAR \"%s\" is not a string."
290                          % varname)
291
292     if instance is None:
293         raise InvalidCNF("Instance field of CNF_VAR \"%s\" is empty."
294                          % varname)
295     elif not isinstance(instance, int):
296         raise InvalidCNF("Instance field of CNF_VAR \"%s\" is not an integer."
297                          % varname)
298
299     if number is None:
300         raise InvalidCNF("Number field of CNF_VAR \"%s\" is empty."
301                          % varname)
302     elif not isinstance(number, int):
303         raise InvalidCNF("Number field of CNF_VAR \"%s\" is not an integer."
304                          % varname)
305     elif number < 1:
306         raise InvalidCNF("Number field of CNF_VAR \"%s\" must be positive, not %d."
307                          % (varname, number))
308     else:
309         other = acc.get(number, None)
310         if other is not None:  # already in use
311             raise InvalidCNF("Number field of CNF_VAR \"%s\" already used by variable %s."
312                              % (varname, other))
313         acc[number] = varname
314
315     if nested is True:
316         if parent is None:
317             raise InvalidCNF("Parent field of nested CNF_VAR \"%s\" is empty."
318                              % varname)
319         elif not isinstance(parent, int):
320             raise InvalidCNF("Parent field of CNF_VAR \"%s\" is not an integer."
321                              % varname)
322     else:
323         if parent is not None:
324             raise InvalidCNF("Flat CNF_VAR \"%s\" has nonsensical parent field \"%s\"."
325                              % (varname, parent))
326     return acc
327
328
329 def is_cnf(root):
330     """
331     is_cnf -- Predicate testing "CNF_VAR-ness". Folds the :py:func:`is_valid`
332     predicate over the argument which can be either a well-formed CNF
333     dictionary or a list of CNF_VARs.
334
335     :type root: cnfvar or cnf list
336     :rtype: bool
337
338     Not that if it returns at all, ``is_cnf()`` returns ``True``. Any non
339     well-formed member of the argument will cause the predicate to bail out
340     with an exception during traversal.
341     """
342     cnf = cnf_root(root)
343     if cnf is None:
344         raise InvalidCNF(root)
345     return walk_cnf(cnf, False, is_valid, {}) is not None
346
347
348 def is_cnf_var(obj):
349     """
350     Check whether a dictionary is a valid CNF.
351
352     :param dict obj: dictionary to check
353     :returns: True if the dictionary has all the mandatory fields and no
354               unknown fields, False otherwise
355     :rtype: bool
356     """
357     assert isinstance (obj, dict)
358
359     for f in CNF_FIELD_MANDATORY:
360         if obj.get(f, None) is None:
361             return False
362
363     for f in obj:
364         if f not in CNF_FIELD_KNOWN:
365             return False
366
367     return True
368
369
370 ###############################################################################
371 # DESERIALIZATION
372 ###############################################################################
373
374
375 #
376 # JSON reader for get_cnf -j (the easy part)
377 #
378
379 def make_varname_lowercase(cnfvar):
380     """
381     Custom hook for json decoder: convert variable name to lowercase.
382
383     Since variable names are case insensitive, :py:func:`read_cnf` converts
384     them all to lower case. Downstream users of :py:func:`read_cnf_json` (e.g.
385     :py:class:`simple_cnf.SimpleCnf`) rely on lowercase variable names.
386
387     :param dict cnfvar: JSON "object" converted into a dict
388     :returns: same as input but if field `varname` is present, its value is
389               converted to lower case
390     :rtype: dict with str keys
391     """
392     try:
393         cnfvar['varname'] = cnfvar['varname'].lower()
394     except KeyError:      # there is no "varname" field
395         pass
396     except AttributeError:   # cnfvar['varname'] is not a string
397         pass
398     return cnfvar
399
400
401 def read_cnf_json(cnfdata):
402     """
403     Read json data from cnf data bytes.
404
405     :param bytes cnfdata: config data
406     :return: the parsed json data
407     :rtype: str
408
409     .. note:: The JSON module does not decode data for all versions
410         of Python 3 so we handle the decoding ourselves.
411     """
412     if isinstance (cnfdata, bytes) is True:
413         cnfdata = from_latin1 (cnfdata)
414     cnf_json = json.loads(cnfdata, object_hook=make_varname_lowercase)
415     if is_cnf(cnf_json) is False:
416         raise TypeError("Invalid CNF_VAR.")
417     return cnf_json
418
419
420 #
421 # CNF reader (the moderately more complicated part)
422 #
423 # Parsing usually starts from the `read_cnf`, which accepts a string containing
424 # the variables to parse in the same structure as returned by `get_cnf`.
425 #
426 # In the `prepare` function the string is split into lines, and a 3-element
427 # tuple is built. The first (named `current`) and second (named `next`)
428 # elements of this tuple are respectively the first and second non-empty lines
429 # of the input, while the third is a list of the remaining lines. This tuple is
430 # named `state` in the implementation below, and it is passed around during
431 # parsing. The `get` and `peek` functions are used to easily retrieve the
432 # `current` and `next` items from the "state".
433 #
434 # When we "advance" the state, we actually drop the "current" element,
435 # replacing it with the "next", while a new "next" is popped from the list of
436 # remaining lines. Parsing is done this way because we need to look ahead at
437 # the next line -- if it is a child it needs to be appended to the `children`
438 # property of the current line.
439 #
440 # Regular expressions are used to extract important information from the CNF
441 # lines. Finally, once parsing is completed, a dictionary is returned. The dict
442 # has the same structure as the serialized JSON output returned by
443 # `get_cnf -j`.
444 #
445
446
447 def read_cnf(data):
448     """
449     Read cnf data from data bytes.
450
451     :param bytes data: raw data
452     :return: the parsed cnf data
453     :rtype: {str, {str, str or int}}
454     """
455     if isinstance(data, str):
456         data = to_latin1(data)
457     state = prepare(data)
458     if state is None:
459         raise InvalidCNF("Empty input string.")
460
461     cnf = parse_cnf_root(state)
462     if is_cnf(cnf) is False:
463         raise TypeError("Invalid CNF_VAR.")
464     return {"cnf": cnf}
465
466
467 def prepare(raw):
468     """
469     Build 3-element iterable from a CNF string dump.
470
471     :param raw: string content as returned by `get_cnf`
472     :type raw: bytes
473     :returns: 3-element tuple, where the first two elements are the first two
474               lines of the output and the third is a list containing the rest
475               of the lines in reverse.
476     :rtype: (str * str option * str list) option
477     """
478     lines = raw.splitlines()
479     lines.reverse()
480     try:
481         first = lines.pop()
482     except IndexError:
483         return None
484
485     try:
486         second = lines.pop()
487     except IndexError:
488         second = None
489
490     first = first.strip()
491     if first == b"":
492         return advance((first, second, lines))
493
494     return (first, second, lines)
495
496
497 def advance(cns):
498     """
499     Pop the next line from the stream, advancing the tuple.
500
501     :param cns: a 3-element tuple containing two CNF lines and a list of the
502                 remaining lines
503     :type cnd: (str, str, [str])
504     :returns: a new tuple with a new item popped from the list of lines
505     :rtype cnd: (str, str, [str])
506     """
507     current, next, stream = cns
508     if next is None:  # reached end of stream
509         return None
510     current = next
511
512     try:
513         next = stream.pop()
514         next = next.strip()
515     except IndexError:
516         next = None
517
518     if current == "":  # skip blank lines
519         return advance((current, next, stream))
520     return (current, next, stream)
521
522
523 def get(cns):
524     """
525     Get the current line from the state without advancing it.
526
527     :param cns: a 3-element tuple containing two CNF lines and a list of the
528                 remaining lines
529     :type cnd: (str, str, [str])
530     :returns: the CNF line stored as `current`
531     :rtype: str
532     """
533     current, _, _ = cns
534     return current
535
536
537 def peek(cns):
538     """
539     Get the next line from the state without advancing it.
540
541     :param cns: a 3-element tuple containing two CNF lines and a list of the
542                 remaining lines
543     :type cnd: (str, str, [str])
544     :returns: the CNF line stored as `next`
545     :rtype: str
546     """
547     _, next, _ = cns
548     return next
549
550
551 def parse_cnf_root(state):
552     """
553     Iterate over and parse a list of CNF lines.
554
555     :param state: a 3-element tuple containing two lines and a list of the
556                   remaining lines
557     :type state: (str, str, [str])
558     :returns: a list of parsed CNF variables
559     :rtype: [dict]
560
561     The function will parse the first element from the `state` tuple, then read
562     the next line to see if it is a child variable. If it is, it will be
563     appended to the last parsed CNF, otherwise top-level parsing is done
564     normally.
565     """
566     lines = []
567     current = get(state)
568     while state:
569         cnf_line = read_base_line(current)
570         if cnf_line is not None:
571             lines.append(cnf_line)
572             state = advance(state)
573             if state is None:  # -> nothing left to do
574                 break
575             current = get(state)
576             parent = get_parent(current)  # peek at next line
577             if parent is not None:  # -> recurse into children
578                 (state, children, _parent) = parse_cnf_children(state, parent)
579                 cnf_line["children"] = children
580                 if state is None:
581                     break
582                 current = get(state)
583         else:
584             state = advance(state)
585             if state is None:
586                 break
587             current = get(state)
588     return lines
589
590
591 def parse_cnf_children(state, parent):
592     """
593     Read and parse child CNFs of a given parent until there is none left.
594
595     :param state: a 3-element tuple containing two lines and a list of the
596                   remaining lines
597     :type state: (str, str, [str])
598     :param parent: id of the parent whose children we are looking for
599     :type parent: int
600     :returns: a 3-element tuple with the current state, a list of children of
601               the given parent and the parent ID
602     :rtype: (tuple, [str], int)
603
604     The function will recursively parse child lines from the `state` tuple
605     until one of these conditions is satisfied:
606
607     1. the input is exhausted
608     2. the next CNF line
609         2.1. is a toplevel line
610         2.2. is a child line whose parent has a lower parent number
611
612     Conceptually, 2.1 is a very similar to 2.2 but due to the special status of
613     toplevel lines in CNF we need to handle them separately.
614
615     Note that since nesting of CNF vars is achieved via parent line numbers,
616     lines with different parents could appear out of order. libcnffile will
617     happily parse those and still assign children to the specified parent:
618
619     ::
620         # set_cnf <<THATSALL
621         1 USER,1337: "l33t_h4x0r"
622         2    (1) USER_GROUP_MEMBER_REF,0: "2"
623         4 USER,1701: "picard"
624         5    (4) USER_GROUP_MEMBER_REF,0: "2"
625         6    (4) USER_PASSWORD,0: "engage"
626         3    (1) USER_PASSWORD,0: "hacktheplanet"
627         THATSALL
628         # get_cnf user 1337
629         1 USER,1337: "l33t_h4x0r"
630         2    (1) USER_GROUP_MEMBER_REF,0: "2"
631         3    (1) USER_PASSWORD,0: "hacktheplanet"
632         # get_cnf user 1701
633         1 USER,1701: "picard"
634         2    (1) USER_GROUP_MEMBER_REF,0: "2"
635         3    (1) USER_PASSWORD,0: "engage"
636
637     It is a limitation of ``cnfvar.py`` that it cannot parse CNF data
638     structured like the above example: child lists are only populated from
639     subsequent CNF vars using the parent number solely to track nesting levels.
640     The parser does not keep track of line numbers while traversing the input
641     so it doesn’t support retroactively assigning a child to anything else but
642     the immediate parent.
643     """
644     lines = []
645     current = get(state)
646     while True:
647         cnf_line = read_child_line(current)
648         if cnf_line is not None:
649             lines.append(cnf_line)
650             state = advance(state)
651             if state is None:
652                 break
653             current = get(state)
654             new_parent = get_parent(current)
655             if new_parent is None:
656                 # drop stack
657                 return (state, lines, None)
658             if new_parent > parent:
659                 # parent is further down in hierarchy -> new level
660                 (state, children, new_parent) = \
661                     parse_cnf_children (state, new_parent)
662                 if state is None:
663                     break
664                 cnf_line["children"] = children
665                 current = get(state)
666                 new_parent = get_parent(current)
667                 if new_parent is None:
668                     # drop stack
669                     return (state, lines, None)
670             if new_parent < parent:
671                 # parent is further up in hierarchy -> pop level
672                 return (state, lines, new_parent)
673             # new_parent == parent -> continue parsing on same level
674     return (state, lines, parent)
675
676
677 def get_parent(line):
678     """
679     Extract the ID of the parent for a given CNF line.
680
681     :param str line: CNF line
682     :returns: parent ID or None if no parent is found
683     :rtype: int or None
684     """
685     match = re.match(grab_parent_pattern, line)
686     if match is None:  # -> no parent
687         return None
688     return int(match.groups()[0])
689
690
691 def read_base_line(line):
692     """
693     Turn one top-level CNF line into a dictionary.
694
695     :param str line: CNF line
696     :rtype: {str: Any}
697
698     This performs the necessary decoding on values to obtain proper Python
699     strings from 8-bit encoded CNF data.
700
701     The function only operates on individual lines. Argument strings that
702     contain data for multiple lines â€“ this includes child lines of the current
703     CNF var! â€“ will trigger a parsing exception.
704     """
705     if len(line.strip()) == 0:
706         return None  # ignore empty lines
707     if line[0] == b"#":
708         return None  # ignore comments
709
710     match = re.match(base_line_pattern, line)
711     if match is None:
712         raise MalformedCNF("Syntax error in line \"\"\"%s\"\"\"" % line)
713     number, varname, instance, data, comment = match.groups()
714     return {
715         "number"   : marshal_in_number   (number),
716         "varname"  : marshal_in_varname  (varname),
717         "instance" : marshal_in_instance (instance),
718         "data"     : marshal_in_data     (data),
719         "comment"  : marshal_in_comment  (comment),
720     }
721
722
723 def read_child_line(line):
724     """
725     Turn one child CNF line into a dictionary.
726
727     :param str line: CNF line
728     :rtype: {str: Any}
729
730     This function only operates on individual lines. If the argument string is
731     syntactically valid but contains input representing multiple CNF vars, a
732     parse error will be thrown.
733     """
734     if len(line.strip()) == 0:
735         return None  # ignore empty lines
736     if line[0] == "#":
737         return None  # ignore comments
738
739     match = re.match(child_line_pattern, line)
740     if match is None:
741         raise MalformedCNF("Syntax error in child line \"\"\"%s\"\"\""
742                            % from_latin1 (line))
743     number, parent, varname, instance, data, comment = match.groups()
744     return {
745         "number"   : marshal_in_number   (number),
746         "parent"   : marshal_in_parent   (parent),
747         "varname"  : marshal_in_varname  (varname),
748         "instance" : marshal_in_instance (instance),
749         "data"     : marshal_in_data     (data),
750         "comment"  : marshal_in_comment  (comment),
751     }
752
753
754 ###############################################################################
755 # SERIALIZATION
756 ###############################################################################
757
758
759 cnf_line_nest_indent = "  "
760 cnf_line_base_fmt    = "%d %s,%d: \"%s\""
761 cnf_line_child_fmt   = "%d %s(%d) %s,%d: \"%s\""
762
763
764 def format_cnf_vars(da, var):
765     """
766     Return a list of formatted cnf_line byte strings.
767
768     :param da: a tuple where the first element is the depth (0 = top-level,
769                >1 = child CNF) and the second is the string being built.
770     :type da: (int, str)
771     :param var: the CNF element to convert to string in the current iteration
772     :type var: dict
773     :returns: a tuple like `da`, where the second element should contain all
774               converted CNFs
775     :rtype: (int, str)
776
777     This function is meant to be passed to the :py:func:`functools.reduce`
778     function.
779
780     The variable names are uppercased unconditionally because while ``get_cnf``
781     is case-indifferent for variable names, ``set_cnf`` isn’t.
782     """
783     depth, acc = da
784     line = None
785     if depth > 0:
786         line = cnf_line_child_fmt \
787             % (var["number"],
788                cnf_line_nest_indent * depth,
789                var["parent"],
790                var["varname"].upper(),
791                var["instance"],
792                var["data"])
793     else:
794         line = cnf_line_base_fmt \
795             % (var["number"],
796                var["varname"].upper(),
797                var["instance"],
798                var["data"])
799
800     comment = var.get("comment", None)
801     if comment and len(comment) != 0:
802         line = line + (" # %s" % comment)
803
804     acc.append(to_latin1(line))
805
806     children = var.get("children", None)
807     if children is not None:
808         (_, acc) = functools.reduce(format_cnf_vars, children, (depth + 1, acc))
809
810     return (depth, acc)
811
812
813 def cnf_root(root):
814     """
815     Extract a list of CNFs from a given structure.
816
817     :param root: list of CNFs or a CNF dictionary
818     :type root: [dict] or dict
819     :raises: :py:class:`TypeError` if no CNFs can be extracted
820     :returns: list with one or more CNF objects
821     :rtype: [dict]
822
823     Output varies depending on a few conditions:
824     - If `root` is a list, return it right away
825     - If `root` is a dict corresponding to a valid CNF value, return it wrapped
826       in a list
827     - If `root` is a dict with a `cnf` key containg a list (as the JSON
828       returned by `get_cnf -j`), return the value
829     - Otherwise, raise an error
830     """
831     if isinstance(root, list):
832         return root
833     if not isinstance(root, dict):
834         raise TypeError(
835             "Expected dictionary of CNF_VARs, got %s." % type(root))
836     if is_cnf_var(root):
837         return [root]
838     cnf = root.get("cnf", None)
839     if not isinstance(cnf, list):
840         raise TypeError("Expected list of CNF_VARs, got %s." % type(cnf))
841     return cnf
842
843
844 def normalize_cnf(cnf):
845     """
846     Ensure the output conforms to set_cnf()’s expectations.
847
848     :param cnf: list of CNF objects to normalize
849     :type cnf: [dict]
850     :returns: normalized list
851     :rtype: [list]
852     """
853     if isinstance(cnf, list) is False:
854         raise MalformedCNF("expected list of CNF_VARs, got [%s]" % type(cnf))
855     def norm(var):
856         vvar = \
857             { "number"   : var ["number"]
858             , "varname"  : var ["varname"].upper()
859             , "instance" : var ["instance"]
860             , "data"     : var ["data"]
861             }
862
863         children = var.get("children", None)
864         if children is not None:
865             vvar ["children"] = normalize_cnf(children)
866
867         parent = var.get("parent", None)
868         if parent is not None:
869             vvar ["parent"] = var["parent"]
870
871         comment = var.get("comment", None)
872         if comment is not None:
873             vvar ["comment"] = var["comment"]
874
875         return vvar
876
877     return [norm(var) for var in cnf]
878
879
880 ###############################################################################
881 # TRAVERSAL
882 ###############################################################################
883
884
885 def walk_cnf(cnf, nested, fun, acc):
886     """
887     Depth-first traversal of a CNF tree.
888
889     :type cnf: cnf list
890     :type nested: bool
891     :type fun: 'a -> bool -> (cnf stuff) -> 'a
892     :type acc: 'a
893     :rtype: 'a
894
895     Executes ``fun`` recursively for each node in the tree. The function
896     receives the accumulator ``acc`` which can be of an arbitrary type as first
897     argument. The second argument is a flag indicating whether the current
898     CNF var is a child (if ``True``) or a parent var. CNF member fields are
899     passed via named optional arguments.
900     """
901     for var in cnf:
902         acc = fun(acc,
903                   nested,
904                   comment=var.get("comment", None),
905                   data=var.get("data", None),
906                   instance=var.get("instance", None),
907                   number=var.get("number", None),
908                   parent=var.get("parent", None),
909                   varname=var.get("varname", None))
910         children = var.get("children", None)
911         if children is not None:
912             acc = walk_cnf(children, True, fun, acc)
913     return acc
914
915
916 def renumber_vars(root, parent=None, toplevel=False):
917     """
918     Number cnfvars linearly.
919
920     If *parent* is specified, numbering will start at this offset. Also, the
921     VAR *root* will be assigned this number as a parent lineno unless
922     *toplevel* is set (the root var in a CNF tree obviously can’t have a
923     parent).
924
925     The *toplevel* parameter is useful when renumbering an existing variable
926     starting at a given offset without at the same time having that offset
927     assigned as a parent.
928     """
929     root = cnf_root (root)
930     i = parent or 0
931     for var in root:
932         i += 1
933         var["number"] = i
934         if toplevel is False and parent is not None:
935             var["parent"] = parent
936         children = var.get("children", None)
937         if children is not None:
938             i = renumber_vars(children, parent=i, toplevel=False)
939     return i
940
941
942 def count_vars(root):
943     """
944     Traverse the cnf structure recursively, counting VAR objects (CNF lines).
945     """
946     cnf = cnf_root(root)
947     if cnf is None:
948         raise InvalidCNF(root)
949     return walk_cnf(cnf, True, lambda n, _nested, **_kwa: n + 1, 0)
950
951 #
952 # querying
953 #
954
955
956 def get_vars(cnf, data=None, instance=None):
957     """
958     get_vars -- Query a CNF_VAR structure. Skims the *toplevel* of the CNF_VAR
959     structure for entries with a matching `data` or `instance` field.
960
961     :type cnf: CNF_VAR
962     :type data: str
963     :type instance: int
964     :rtype: CNF_VAR
965     :returns: The structure containing only references to the
966              matching variables. Containing an empty list of
967              variables in case there is no match.
968
969     Values are compared literally. If both ``instance`` and ``data`` are
970     specified, vars will be compared against both.
971     """
972     cnf = cnf["cnf"]
973     if cnf:
974         criterion = lambda _var: False
975         if data:
976             if instance:
977                 criterion = lambda var: var[
978                     "data"] == data and var["instance"] == instance
979             else:
980                 criterion = lambda var: var["data"] == data
981         elif instance:
982             criterion = lambda var: var["instance"] == instance
983
984         return {"cnf": [var for var in cnf if criterion(var) is True]}
985
986     return {"cnf": []}
987
988
989 ###############################################################################
990 # PRINTING/DUMPING
991 ###############################################################################
992
993
994 #
995 # Print/dump raw CNF values
996 #
997
998 def output_cnf(root, out, renumber=False):
999     """
1000     Dump a textual representation of given CNF VAR structure to given stream.
1001
1002     Runs :py:func:`format_cnf_vars` on the input (`root`) and then writes that
1003     to the given file-like object (out).
1004
1005     :param root: a CNF_VAR structure
1006     :type root: dict or list or anything that :py:func:`cnf_root` accepts
1007     :param out: file-like object or something with a `write(str)` function
1008     :param bool renumber: Whether to renumber cnfvars first
1009
1010     Files are converted to the 8-bit format expected by CNF so they can be fed
1011     directly into libcnffile.
1012     """
1013     cnf = cnf_root(root)
1014     if renumber is True:
1015         _count = renumber_vars(root)
1016     if is_cnf(cnf) is True:
1017         (_, lines) = functools.reduce(format_cnf_vars, cnf, (0, []))
1018         if isinstance(out, (io.RawIOBase, io.BufferedIOBase)):
1019             out.write (b"\n".join (lines))
1020             out.write (b"\n")
1021         else:   # either subclass of io.TextIOBase or unknown
1022             out.write ("\n".join (map (from_latin1, lines)))
1023             out.write ("\n")
1024
1025
1026 def dump_cnf_bytes (root, renumber=False):
1027     """
1028     Serialize CNF var structure, returning the result as a byte sequence.
1029     """
1030     cnf = cnf_root(root)
1031     out = io.BytesIO()
1032     output_cnf(root, out, renumber=renumber)
1033     res = out.getvalue()
1034     out.close()
1035     return res
1036
1037
1038 def dump_cnf_string(root, renumber=False):
1039     """
1040     Serialize CNF var structure, returning a latin1-encode byte string.
1041
1042     .. todo::this is identical to :py:func:`dump_cnf_bytes`!
1043     """
1044     cnf = cnf_root(root)
1045     out = io.BytesIO()
1046     output_cnf(root, out, renumber=renumber)
1047     res = out.getvalue()
1048     out.close()
1049     return res
1050
1051
1052 def print_cnf(root, out=None, renumber=False):
1053     """
1054     Print given CNF_VAR structure to stdout (or other file-like object).
1055
1056     Note that per default the config is printed to sys.stdout using the shell's
1057     preferred encoding. If the shell cannot handle unicode this might raise
1058     `UnicodeError`.
1059
1060     All params forwarded to :py:func:`output_cnf`. See args there.
1061     """
1062     if root is not None:
1063         output_cnf(root, out or sys.stdout, renumber=renumber)
1064
1065
1066 def write_cnf(*argv, **kw_argv):
1067     """Alias for :py:func:`print_cnf`."""
1068     print_cnf(*argv, **kw_argv)
1069
1070
1071 def print_cnf_raw(root, out=None):
1072     """`if root is not None: out.write(root)`."""
1073     if root is not None:
1074         out.write(root)
1075
1076
1077 def write_cnf_raw(*argv, **kw_argv):
1078     """Alias for :py:func:`print_cnf_raw`."""
1079     print_cnf_raw(*argv, **kw_argv)
1080
1081
1082 #
1083 # Print/dump CNF values in JSON format
1084 #
1085
1086
1087 def output_json(root, out, renumber=False):
1088     """
1089     Dump CNF_VAR structure to file-like object in json format.
1090
1091     :param root: CNF_VAR structure
1092     :type root: dict or list or anything that :py:func:`cnf_root` accepts
1093     :param out: file-like object, used as argument to :py:func:`json.dumps` so
1094                 probably has to accept `str` (as opposed to `bytes`).
1095     :param bool renumber: whether to renumber variables before dupming.
1096     """
1097     # if not isinstance(out, file):
1098         #raise TypeError("%s (%s) is not a stream." % (out, type(out)))
1099     if renumber is True:
1100         _count = renumber_vars(root)
1101     if is_cnf(root) is True:
1102         root ["cnf"] = normalize_cnf(cnf_root (root))
1103         data = json.dumps(root)
1104         out.write(data)
1105         out.write("\n")
1106     # TODO: else raise value error?
1107
1108
1109 def dump_json_string(root, renumber=False):
1110     """
1111     Serialize CNF var structure as JSON, returning the result as a string.
1112     """
1113     out = io.StringIO()
1114     output_json(root, out, renumber=renumber)
1115     res = out.getvalue()
1116     out.close()
1117     return res
1118
1119
1120 def print_cnf_json(root, out=None, renumber=False):
1121     """
1122     Print CNF_VAR structure in json format to stdout.
1123
1124     Calls :py:func:`output_json` with `sys.stdout` if `out` is not given or
1125     `None`.
1126     """
1127     if root is not None:
1128         output_json(root, out or sys.stdout, renumber=renumber)
1129
1130
1131 def write_cnf_json(*argv, **kw_argv):
1132     """Alias for :py:func:`print_cnf_json`."""
1133     print_cnf_json(*argv, **kw_argv)
1134
1135
1136
1137 ###############################################################################
1138 # ENTRY POINT FOR DEVELOPMENT
1139 ###############################################################################
1140
1141
1142 def usage():
1143     print("usage: cnfvar.py -"      , file=sys.stderr)
1144     print(""                        , file=sys.stderr)
1145     print("    Read CNF from stdin.", file=sys.stderr)
1146     print(""                        , file=sys.stderr)
1147
1148
1149 def main(argv):
1150     if len(argv) > 1:
1151         first = argv[1]
1152         if first == "-":
1153             cnf = read_cnf(sys.stdin.buffer.read())
1154             print_cnf(cnf)
1155             return 0
1156         elif first == "test":
1157             cnf = read_cnf(sys.stdin.buffer.read())
1158             cnff = get_vars(cnf, instance=2, data="FAX")
1159             print_cnf(cnff)
1160             return 0
1161     usage()
1162     return -1
1163
1164 if __name__ == "__main__":
1165     sys.exit(main(sys.argv))