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