autopep8'ed
[myplc.git] / plc_config.py
1 #!/usr/bin/env python3
2 #
3 # Merge PlanetLab Central (PLC) configuration files into a variety of
4 # output formats. These files represent the global configuration for a
5 # PLC installation.
6 #
7 # Mark Huang <mlhuang@cs.princeton.edu>
8 # Copyright (C) 2006 The Trustees of Princeton University
9 #
10
11 import codecs
12 import os
13 import re
14 import sys
15 import textwrap
16 import time
17 import traceback
18 import xml.dom.minidom
19 from xml.parsers.expat import ExpatError
20 from io import StringIO
21 from optparse import OptionParser
22
23
24 class ConfigurationException(Exception):
25     pass
26
27
28 class PLCConfiguration:
29     """
30     Configuration file store. Optionally instantiate with a file path
31     or object:
32
33     plc = PLCConfiguration()
34     plc = PLCConfiguration(fileobj)
35     plc = PLCConfiguration("/etc/planetlab/plc_config.xml")
36
37     You may load() additional files later, which will be merged into
38     the current configuration:
39
40     plc.load("/etc/planetlab/local.xml")
41
42     You may also save() the configuration. If a file path or object is
43     not specified, the configuration will be written to the file path
44     or object that was first loaded.
45
46     plc.save()
47     plc.save("/etc/planetlab/plc_config.xml")
48     """
49
50     def __init__(self, file=None):
51         impl = xml.dom.minidom.getDOMImplementation()
52         self._dom = impl.createDocument(None, "configuration", None)
53         self._variables = {}
54         self._packages = {}
55         self._files = []
56
57         if file is not None:
58             self.load(file)
59
60     def _get_text(self, node):
61         """
62         Get the text of a text node.
63         """
64
65         if node.firstChild and \
66            node.firstChild.nodeType == node.TEXT_NODE:
67             if node.firstChild.data is None:
68                 # Interpret simple presence of node as "", not NULL
69                 return ""
70             else:
71                 return node.firstChild.data
72
73         return None
74
75     def _get_text_of_child(self, parent, name):
76         """
77         Get the text of a (direct) child text node.
78         """
79
80         for node in parent.childNodes:
81             if node.nodeType == node.ELEMENT_NODE and \
82                node.tagName == name:
83                 return self._get_text(node)
84
85         return None
86
87     def _set_text(self, node, data):
88         """
89         Set the text of a text node.
90         """
91
92         if node.firstChild and \
93            node.firstChild.nodeType == node.TEXT_NODE:
94             if data is None:
95                 node.removeChild(node.firstChild)
96             else:
97                 node.firstChild.data = data
98         elif data is not None:
99             text = TrimText()
100             text.data = data
101             node.appendChild(text)
102
103     def _set_text_of_child(self, parent, name, data):
104         """
105         Set the text of a (direct) child text node.
106         """
107
108         for node in parent.childNodes:
109             if node.nodeType == node.ELEMENT_NODE and \
110                node.tagName == name:
111                 self._set_text(node, data)
112                 return
113
114         child = TrimTextElement(name)
115         self._set_text(child, data)
116         parent.appendChild(child)
117
118     def _category_element_to_dict(self, category_element):
119         """
120         Turn a <category> element into a dictionary of its attributes
121         and child text nodes.
122         """
123
124         category = {}
125         category['id'] = category_element.getAttribute('id').lower()
126         for node in category_element.childNodes:
127             if node.nodeType == node.ELEMENT_NODE and \
128                node.tagName in ['name', 'description']:
129                 category[node.tagName] = self._get_text_of_child(
130                     category_element, node.tagName)
131         category['element'] = category_element
132
133         return category
134
135     def _variable_element_to_dict(self, variable_element):
136         """
137         Turn a <variable> element into a dictionary of its attributes
138         and child text nodes.
139         """
140
141         variable = {}
142         variable['id'] = variable_element.getAttribute('id').lower()
143         if variable_element.hasAttribute('type'):
144             variable['type'] = variable_element.getAttribute('type')
145         for node in variable_element.childNodes:
146             if node.nodeType == node.ELEMENT_NODE and \
147                node.tagName in ['name', 'value', 'description']:
148                 variable[node.tagName] = self._get_text_of_child(
149                     variable_element, node.tagName)
150         variable['element'] = variable_element
151
152         return variable
153
154     def _group_element_to_dict(self, group_element):
155         """
156         Turn a <group> element into a dictionary of its attributes
157         and child text nodes.
158         """
159
160         group = {}
161         for node in group_element.childNodes:
162             if node.nodeType == node.ELEMENT_NODE and \
163                node.tagName in ['id', 'name', 'default', 'description', 'uservisible']:
164                 group[node.tagName] = self._get_text_of_child(
165                     group_element, node.tagName)
166         group['element'] = group_element
167
168         return group
169
170     def _packagereq_element_to_dict(self, packagereq_element):
171         """
172         Turns a <packagereq> element into a dictionary of its attributes
173         and child text nodes.
174         """
175
176         package = {}
177         if packagereq_element.hasAttribute('type'):
178             package['type'] = packagereq_element.getAttribute('type')
179         package['name'] = self._get_text(packagereq_element)
180         package['element'] = packagereq_element
181
182         return package
183
184     def load(self, file="/etc/planetlab/plc_config.xml"):
185         """
186         Merge file into configuration store.
187         """
188
189         try:
190             dom = xml.dom.minidom.parse(file)
191         except ExpatError as e:
192             raise ConfigurationException(e)
193
194         if isinstance(file, str):
195             self._files.append(os.path.abspath(file))
196
197         # Parse <variables> section
198         for variables_element in dom.getElementsByTagName('variables'):
199             for category_element in variables_element.getElementsByTagName('category'):
200                 category = self._category_element_to_dict(category_element)
201                 self.set(category, None)
202
203                 for variablelist_element in category_element.getElementsByTagName('variablelist'):
204                     for variable_element in variablelist_element.getElementsByTagName('variable'):
205                         variable = self._variable_element_to_dict(
206                             variable_element)
207                         self.set(category, variable)
208
209         # Parse <comps> section
210         for comps_element in dom.getElementsByTagName('comps'):
211             for group_element in comps_element.getElementsByTagName('group'):
212                 group = self._group_element_to_dict(group_element)
213                 self.add_package(group, None)
214
215                 for packagereq_element in group_element.getElementsByTagName('packagereq'):
216                     package = self._packagereq_element_to_dict(
217                         packagereq_element)
218                     self.add_package(group, package)
219
220     def save(self, file=None):
221         """
222         Write configuration store to file.
223         """
224
225         if file is None:
226             if self._files:
227                 file = self._files[0]
228             else:
229                 file = "/etc/planetlab/plc_config.xml"
230
231         if isinstance(file, str):
232             fileobj = open(file, 'w')
233         else:
234             fileobj = file
235
236         fileobj.seek(0)
237         fileobj.write(self.output_xml())
238         fileobj.truncate()
239
240         fileobj.close()
241
242     def verify(self, default, read, verify_variables={}):
243         """ Confirm that the existing configuration is consistent
244             according to the checks below.
245
246             It looks for filled-in values in the order of, local object (self),
247             followed by cread (read values), and finally default values.
248
249         Arguments:
250
251             default configuration
252             site configuration
253             list of category/variable tuples to validate in these configurations
254
255         Returns:
256
257             dict of values for the category/variables passed in
258             If an exception is found, ConfigurationException is raised.
259
260         """
261
262         validated_variables = {}
263         for category_id, variable_id in verify_variables.items():
264             category_id = category_id.lower()
265             variable_id = variable_id.lower()
266             variable_value = None
267             sources = (self, read, default)
268             for source in sources:
269                 (category_value, variable_value) = source.get(
270                     category_id, variable_id)
271                 if variable_value != None:
272                     entry = validated_variables.get(category_id, [])
273                     entry.append(variable_value['value'])
274                     validated_variables["%s_%s" % (
275                         category_id.upper(), variable_id.upper())] = entry
276                     break
277             if variable_value == None:
278                 raise ConfigurationException("Cannot find %s_%s)" %
279                                              (category_id.upper(),
280                                               variable_id.upper()))
281         return validated_variables
282
283     def get(self, category_id, variable_id):
284         """
285         Get the specified variable in the specified category.
286
287         Arguments:
288
289         category_id = unique category identifier (e.g., 'plc_www')
290         variable_id = unique variable identifier (e.g., 'port')
291
292         Returns:
293
294         variable = { 'id': "variable_identifier",
295                      'type': "variable_type",
296                      'value': "variable_value",
297                      'name': "Variable name",
298                      'description': "Variable description" }
299         """
300
301         if category_id.lower() in self._variables:
302             (category, variables) = self._variables[category_id]
303             if variable_id.lower() in variables:
304                 variable = variables[variable_id]
305             else:
306                 variable = None
307         else:
308             category = None
309             variable = None
310
311         return (category, variable)
312
313     def delete(self, category_id, variable_id):
314         """
315         Delete the specified variable from the specified category. If
316         variable_id is None, deletes all variables from the specified
317         category as well as the category itself.
318
319         Arguments:
320
321         category_id = unique category identifier (e.g., 'plc_www')
322         variable_id = unique variable identifier (e.g., 'port')
323         """
324
325         if category_id.lower() in self._variables:
326             (category, variables) = self._variables[category_id]
327             if variable_id is None:
328                 category['element'].parentNode.removeChild(category['element'])
329                 del self._variables[category_id]
330             elif variable_id.lower() in variables:
331                 variable = variables[variable_id]
332                 variable['element'].parentNode.removeChild(variable['element'])
333                 del variables[variable_id]
334
335     def set(self, category, variable):
336         """
337         Add and/or update the specified variable. The 'id' fields are
338         mandatory. If a field is not specified and the category and/or
339         variable already exists, the field will not be updated. If
340         'variable' is None, only adds and/or updates the specified
341         category.
342
343         Arguments:
344
345         category = { 'id': "category_identifier",
346                      'name': "Category name",
347                      'description': "Category description" }
348
349         variable = { 'id': "variable_identifier",
350                      'type': "variable_type",
351                      'value': "variable_value",
352                      'name': "Variable name",
353                      'description': "Variable description" }
354         """
355
356         if ('id' not in category
357                 or not isinstance(category['id'], str)):
358             return
359
360         category_id = category['id'].lower()
361
362         if category_id in self._variables:
363             # Existing category
364             (old_category, variables) = self._variables[category_id]
365
366             # Merge category attributes
367             for tag in ['name', 'description']:
368                 if tag in category:
369                     old_category[tag] = category[tag]
370                     self._set_text_of_child(
371                         old_category['element'], tag, category[tag])
372
373             category_element = old_category['element']
374         else:
375             # Merge into DOM
376             category_element = self._dom.createElement('category')
377             category_element.setAttribute('id', category_id)
378             for tag in ['name', 'description']:
379                 if tag in category:
380                     self._set_text_of_child(
381                         category_element, tag, category[tag])
382
383             if self._dom.documentElement.getElementsByTagName('variables'):
384                 variables_element = self._dom.documentElement.getElementsByTagName('variables')[
385                     0]
386             else:
387                 variables_element = self._dom.createElement('variables')
388                 self._dom.documentElement.appendChild(variables_element)
389             variables_element.appendChild(category_element)
390
391             # Cache it
392             category['element'] = category_element
393             variables = {}
394             self._variables[category_id] = (category, variables)
395
396         if (variable is None or 'id' not in variable
397                 or not isinstance(variable['id'], str)):
398             return
399
400         variable_id = variable['id'].lower()
401
402         if variable_id in variables:
403             # Existing variable
404             old_variable = variables[variable_id]
405
406             # Merge variable attributes
407             for attribute in ['type']:
408                 if attribute in variable:
409                     old_variable[attribute] = variable[attribute]
410                     old_variable['element'].setAttribute(
411                         attribute, variable[attribute])
412             for tag in ['name', 'value', 'description']:
413                 if tag in variable:
414                     old_variable[tag] = variable[tag]
415                     self._set_text_of_child(
416                         old_variable['element'], tag, variable[tag])
417         else:
418             # Merge into DOM
419             variable_element = self._dom.createElement('variable')
420             variable_element.setAttribute('id', variable_id)
421             for attribute in ['type']:
422                 if attribute in variable:
423                     variable_element.setAttribute(
424                         attribute, variable[attribute])
425             for tag in ['name', 'value', 'description']:
426                 if tag in variable:
427                     self._set_text_of_child(
428                         variable_element, tag, variable[tag])
429
430             if category_element.getElementsByTagName('variablelist'):
431                 variablelist_element = category_element.getElementsByTagName('variablelist')[
432                     0]
433             else:
434                 variablelist_element = self._dom.createElement('variablelist')
435                 category_element.appendChild(variablelist_element)
436             variablelist_element.appendChild(variable_element)
437
438             # Cache it
439             variable['element'] = variable_element
440             variables[variable_id] = variable
441
442     def locate_varname(self, varname):
443         """
444         Locates category and variable from a variable's (shell) name
445
446         Returns:
447         (variable, category) when found
448         (None, None) otherwise
449         """
450
451         for (category_id, (category, variables)) in self._variables.items():
452             for variable in list(variables.values()):
453                 (id, name, value, comments) = self._sanitize_variable(
454                     category_id, variable)
455                 if (id == varname):
456                     return (category, variable)
457         return (None, None)
458
459     def get_package(self, group_id, package_name):
460         """
461         Get the specified package in the specified package group.
462
463         Arguments:
464
465         group_id - unique group id (e.g., 'plc')
466         package_name - unique package name (e.g., 'postgresql')
467
468         Returns:
469
470         package = { 'name': "package_name",
471                     'type': "mandatory|optional" }
472         """
473
474         if group_id.lower() in self._packages:
475             (group, packages) = self._packages[group_id]
476             if package_name in packages:
477                 package = packages[package_name]
478             else:
479                 package = None
480         else:
481             group = None
482             package = None
483
484         return (group, package)
485
486     def delete_package(self, group_id, package_name):
487         """
488         Deletes the specified variable from the specified category. If
489         variable_id is None, deletes all variables from the specified
490         category as well as the category itself.
491
492         Arguments:
493
494         group_id - unique group id (e.g., 'plc')
495         package_name - unique package name (e.g., 'postgresql')
496         """
497
498         if group_id in self._packages:
499             (group, packages) = self._packages[group_id]
500             if package_name is None:
501                 group['element'].parentNode.removeChild(group['element'])
502                 del self._packages[group_id]
503             elif package_name.lower() in packages:
504                 package = packages[package_name]
505                 package['element'].parentNode.removeChild(package['element'])
506                 del packages[package_name]
507
508     def add_package(self, group, package):
509         """
510         Add and/or update the specified package. The 'id' and 'name'
511         fields are mandatory. If a field is not specified and the
512         package or group already exists, the field will not be
513         updated. If package is None, only adds/or updates the
514         specified group.
515
516         Arguments:
517
518         group = { 'id': "group_identifier",
519                   'name': "Group name",
520                   'default': "true|false",
521                   'description': "Group description",
522                   'uservisible': "true|false" }
523
524         package = { 'name': "package_name",
525                     'type': "mandatory|optional" }
526         """
527
528         if 'id' not in group:
529             return
530
531         group_id = group['id']
532
533         if group_id in self._packages:
534             # Existing group
535             (old_group, packages) = self._packages[group_id]
536
537             # Merge group attributes
538             for tag in ['id', 'name', 'default', 'description', 'uservisible']:
539                 if tag in group:
540                     old_group[tag] = group[tag]
541                     self._set_text_of_child(
542                         old_group['element'], tag, group[tag])
543
544             group_element = old_group['element']
545         else:
546             # Merge into DOM
547             group_element = self._dom.createElement('group')
548             for tag in ['id', 'name', 'default', 'description', 'uservisible']:
549                 if tag in group:
550                     self._set_text_of_child(group_element, tag, group[tag])
551
552             if self._dom.documentElement.getElementsByTagName('comps'):
553                 comps_element = self._dom.documentElement.getElementsByTagName('comps')[
554                     0]
555             else:
556                 comps_element = self._dom.createElement('comps')
557                 self._dom.documentElement.appendChild(comps_element)
558             comps_element.appendChild(group_element)
559
560             # Cache it
561             group['element'] = group_element
562             packages = {}
563             self._packages[group_id] = (group, packages)
564
565         if package is None or 'name' not in package:
566             return
567
568         package_name = package['name']
569         if package_name in packages:
570             # Existing package
571             old_package = packages[package_name]
572
573             # Merge variable attributes
574             for attribute in ['type']:
575                 if attribute in package:
576                     old_package[attribute] = package[attribute]
577                     old_package['element'].setAttribute(
578                         attribute, package[attribute])
579         else:
580             # Merge into DOM
581             packagereq_element = TrimTextElement('packagereq')
582             self._set_text(packagereq_element, package_name)
583             for attribute in ['type']:
584                 if attribute in package:
585                     packagereq_element.setAttribute(
586                         attribute, package[attribute])
587
588             if group_element.getElementsByTagName('packagelist'):
589                 packagelist_element = group_element.getElementsByTagName('packagelist')[
590                     0]
591             else:
592                 packagelist_element = self._dom.createElement('packagelist')
593                 group_element.appendChild(packagelist_element)
594             packagelist_element.appendChild(packagereq_element)
595
596             # Cache it
597             package['element'] = packagereq_element
598             packages[package_name] = package
599
600     def variables(self):
601         """
602         Return all variables.
603
604         Returns:
605
606         variables = { 'category_id': (category, variablelist) }
607
608         category = { 'id': "category_identifier",
609                      'name': "Category name",
610                      'description': "Category description" }
611
612         variablelist = { 'variable_id': variable }
613
614         variable = { 'id': "variable_identifier",
615                      'type': "variable_type",
616                      'value': "variable_value",
617                      'name': "Variable name",
618                      'description': "Variable description" }
619         """
620
621         return self._variables
622
623     def packages(self):
624         """
625         Return all packages.
626
627         Returns:
628
629         packages = { 'group_id': (group, packagelist) }
630
631         group = { 'id': "group_identifier",
632                   'name': "Group name",
633                   'default': "true|false",
634                   'description': "Group description",
635                   'uservisible': "true|false" }
636
637         packagelist = { 'package_name': package }
638
639         package = { 'name': "package_name",
640                     'type': "mandatory|optional" }
641         """
642
643         return self._packages
644
645     def _sanitize_variable(self, category_id, variable):
646         assert 'id' in variable
647         # Prepend variable name with category label
648         id = category_id + "_" + variable['id']
649         # And uppercase it
650         id = id.upper()
651
652         if 'type' in variable:
653             type = variable['type']
654         else:
655             type = None
656
657         if 'name' in variable:
658             name = variable['name']
659         else:
660             name = None
661
662         if 'value' in variable and variable['value'] is not None:
663             value = variable['value']
664             if type == "int" or type == "double":
665                 # bash, Python, and PHP do not require that numbers be quoted
666                 pass
667             elif type == "boolean":
668                 # bash, Python, and PHP can all agree on 0 and 1
669                 if value == "true":
670                     value = "1"
671                 else:
672                     value = "0"
673             else:
674                 # bash, Python, and PHP all support strong single quoting
675                 value = "'" + value.replace("'", "\\'") + "'"
676         else:
677             value = None
678
679         if 'description' in variable and variable['description'] is not None:
680             description = variable['description']
681             # Collapse consecutive whitespace
682             description = re.sub(r'\s+', ' ', description)
683             # Wrap comments at 70 columns
684             wrapper = textwrap.TextWrapper()
685             comments = wrapper.wrap(description)
686         else:
687             comments = None
688
689         return (id, name, value, comments)
690
691     def _header(self):
692         header = """
693 DO NOT EDIT. This file was automatically generated at
694 %s from:
695
696 %s
697 """ % (time.asctime(), os.linesep.join(self._files))
698
699         # Get rid of the surrounding newlines
700         return header.strip().split(os.linesep)
701
702     def output_shell(self, show_comments=True, encoding="utf-8"):
703         """
704         Return variables as a shell script.
705         """
706
707         buf = codecs.lookup(encoding)[3](StringIO())
708         buf.writelines(["# " + line + os.linesep for line in self._header()])
709
710         for (category_id, (category, variables)) in self._variables.items():
711             for variable in list(variables.values()):
712                 (id, name, value, comments) = self._sanitize_variable(
713                     category_id, variable)
714                 if show_comments:
715                     buf.write(os.linesep)
716                     if name is not None:
717                         buf.write("# " + name + os.linesep)
718                     if comments is not None:
719                         buf.writelines(
720                             ["# " + line + os.linesep for line in comments])
721                 # bash does not have the concept of NULL
722                 if value is not None:
723                     buf.write(id + "=" + value + os.linesep)
724
725         return buf.getvalue()
726
727     def output_php(self, encoding="utf-8"):
728         """
729         Return variables as a PHP script.
730         """
731
732         buf = codecs.lookup(encoding)[3](StringIO())
733         buf.write("<?php" + os.linesep)
734         buf.writelines(["// " + line + os.linesep for line in self._header()])
735
736         for (category_id, (category, variables)) in self._variables.items():
737             for variable in list(variables.values()):
738                 (id, name, value, comments) = self._sanitize_variable(
739                     category_id, variable)
740                 buf.write(os.linesep)
741                 if name is not None:
742                     buf.write("// " + name + os.linesep)
743                 if comments is not None:
744                     buf.writelines(
745                         ["// " + line + os.linesep for line in comments])
746                 if value is None:
747                     value = 'NULL'
748                 buf.write("define('%s', %s);" % (id, value) + os.linesep)
749
750         buf.write("?>" + os.linesep)
751
752         return buf.getvalue()
753
754     def output_xml(self, encoding="utf-8"):
755         """
756         Return variables in original XML format.
757         """
758
759         buf = codecs.lookup(encoding)[3](StringIO())
760         self._dom.writexml(buf, addindent="  ", indent="",
761                            newl="\n", encoding=encoding)
762
763         return buf.getvalue()
764
765     def output_variables(self, encoding="utf-8"):
766         """
767         Return list of all variable names.
768         """
769
770         buf = codecs.lookup(encoding)[3](StringIO())
771
772         for (category_id, (category, variables)) in self._variables.items():
773             for variable in list(variables.values()):
774                 (id, name, value, comments) = self._sanitize_variable(
775                     category_id, variable)
776                 buf.write(id + os.linesep)
777
778         return buf.getvalue()
779
780     def output_packages(self, encoding="utf-8"):
781         """
782         Return list of all packages.
783         """
784
785         buf = codecs.lookup(encoding)[3](StringIO())
786
787         for (group, packages) in list(self._packages.values()):
788             buf.write(os.linesep.join(list(packages.keys())))
789
790         if buf.tell():
791             buf.write(os.linesep)
792
793         return buf.getvalue()
794
795     def output_groups(self, encoding="utf-8"):
796         """
797         Return list of all package group names.
798         """
799
800         buf = codecs.lookup(encoding)[3](StringIO())
801
802         for (group, packages) in list(self._packages.values()):
803             buf.write(group['name'] + os.linesep)
804
805         return buf.getvalue()
806
807     def output_comps(self, encoding="utf-8"):
808         """
809         Return <comps> section of configuration.
810         """
811
812         if self._dom is None or \
813            not self._dom.getElementsByTagName("comps"):
814             return
815         comps = self._dom.getElementsByTagName("comps")[0]
816
817         impl = xml.dom.minidom.getDOMImplementation()
818         doc = impl.createDocument(None, "comps", None)
819
820         buf = codecs.lookup(encoding)[3](StringIO())
821
822         # Pop it off the DOM temporarily
823         parent = comps.parentNode
824         parent.removeChild(comps)
825
826         doc.replaceChild(comps, doc.documentElement)
827         doc.writexml(buf, encoding=encoding)
828
829         # Put it back
830         parent.appendChild(comps)
831
832         return buf.getvalue()
833
834     def validate_type(self, variable_type, value):
835
836         # ideally we should use the "validate_*" methods in PLCAPI or
837         # even declare some checks along with the default
838         # configuration (using RELAX NG?) but this shall work for now.
839         def ip_validator(val):
840             import socket
841             try:
842                 socket.inet_aton(val)
843                 return True
844             except:
845                 return False
846
847         def email_validator(val):
848             return re.match('\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9._\-]+\.[a-zA-Z]+\Z', val)
849
850         def boolean_validator(val):
851             return val in ['true', 'false']
852
853         validators = {
854             'email': email_validator,
855             'ip': ip_validator,
856             'boolean': boolean_validator,
857         }
858
859         # validate it if not a know type.
860         validator = validators.get(variable_type, lambda x: True)
861         return validator(value)
862
863
864 # xml.dom.minidom.Text.writexml adds surrounding whitespace to textual
865 # data when pretty-printing. Override this behavior.
866 class TrimText(xml.dom.minidom.Text):
867     def writexml(self, writer, indent="", addindent="", newl=""):
868         xml.dom.minidom.Text.writexml(self, writer, "", "", "")
869
870
871 class TrimTextElement(xml.dom.minidom.Element):
872     def writexml(self, writer, indent="", addindent="", newl=""):
873         writer.write(indent)
874         xml.dom.minidom.Element.writexml(self, writer, "", "", "")
875         writer.write(newl)
876
877
878 ####################
879 # GLOBAL VARIABLES
880 #
881 g_configuration = None
882 usual_variables = None
883 config_dir = None
884 service = None
885
886
887 def noop_validator(validated_variables):
888     pass
889
890
891 # historically we could also configure the devel pkg....
892 def init_configuration():
893     global g_configuration
894     global usual_variables, config_dir, service
895
896     usual_variables = g_configuration["usual_variables"]
897     config_dir = g_configuration["config_dir"]
898     service = g_configuration["service"]
899
900     global def_default_config, def_site_config, def_consolidated_config
901     def_default_config = "%s/default_config.xml" % config_dir
902     def_site_config = "%s/configs/site.xml" % config_dir
903     def_consolidated_config = "%s/%s_config.xml" % (config_dir, service)
904
905     global mainloop_usage
906     mainloop_usage = """Available commands:
907  Uppercase versions give variables comments, when available
908  u/U\t\t\tEdit usual variables
909  w\t\t\tWrite
910  r\t\t\tRestart %(service)s service
911  R\t\t\tReload %(service)s service (rebuild config files for sh, python....)
912  q\t\t\tQuit (without saving)
913  h/?\t\t\tThis help
914 ---
915  l/L [<cat>|<var>]\tShow Locally modified variables/values
916  s/S [<cat>|<var>]\tShow variables/values (all, in category, single)
917  e/E [<cat>|<var>]\tEdit variables (all, in category, single)
918 ---
919  c\t\t\tList categories
920  v/V [<cat>|<var>]\tList Variables (all, in category, single)
921 ---
922 Typical usage involves: u, [l,] w, r, q
923 """ % globals()
924
925
926 def usage():
927     command_usage = "%prog [options] [default-xml [site-xml [consolidated-xml]]]"
928     init_configuration()
929     command_usage += """
930 \t default-xml defaults to %s
931 \t site-xml defaults to %s
932 \t consolidated-xml defaults to %s""" % (def_default_config, def_site_config, def_consolidated_config)
933     return command_usage
934
935
936 ####################
937 variable_usage = """Edit Commands :
938 #\tShow variable comments
939 .\tStops prompting, return to mainloop
940 /\tCleans any site-defined value, reverts to default
941 =\tShows default value
942 >\tSkips to next category
943 ?\tThis help
944 """
945
946 ####################
947
948
949 def get_value(config,  category_id, variable_id):
950     (category, variable) = config.get(category_id, variable_id)
951     return variable['value']
952
953
954 def get_type(config, category_id, variable_id):
955     (category, variable) = config.get(category_id, variable_id)
956     return variable['type']
957
958
959 def get_current_value(cread, cwrite, category_id, variable_id):
960     # the value stored in cwrite, if present, is the one we want
961     try:
962         result = get_value(cwrite, category_id, variable_id)
963     except:
964         result = get_value(cread, category_id, variable_id)
965     return result
966
967 # refrain from using plc_config's _sanitize
968
969
970 def get_varname(config,  category_id, variable_id):
971     (category, variable) = config.get(category_id, variable_id)
972     return (category_id+"_"+variable['id']).upper()
973
974 # could not avoid using _sanitize here..
975
976
977 def get_name_comments(config, cid, vid):
978     try:
979         (category, variable) = config.get(cid, vid)
980         (id, name, value, comments) = config._sanitize_variable(cid, variable)
981         return (name, comments)
982     except:
983         return (None, [])
984
985
986 def print_name_comments(config, cid, vid):
987     (name, comments) = get_name_comments(config, cid, vid)
988     if name:
989         print("### %s" % name)
990     if comments:
991         for line in comments:
992             print("# %s" % line)
993     else:
994         print("!!! No comment associated to %s_%s" % (cid, vid))
995
996 ####################
997
998
999 def list_categories(config):
1000     result = []
1001     for (category_id, (category, variables)) in config.variables().items():
1002         result += [category_id]
1003     return result
1004
1005
1006 def print_categories(config):
1007     print("Known categories")
1008     for cid in list_categories(config):
1009         print("%s" % (cid.upper()))
1010
1011 ####################
1012
1013
1014 def list_category(config, cid):
1015     result = []
1016     for (category_id, (category, variables)) in config.variables().items():
1017         if (cid == category_id):
1018             for variable in list(variables.values()):
1019                 result += ["%s_%s" % (cid, variable['id'])]
1020     return result
1021
1022
1023 def print_category(config, cid, show_comments=True):
1024     cid = cid.lower()
1025     CID = cid.upper()
1026     vids = list_category(config, cid)
1027     if (len(vids) == 0):
1028         print("%s : no such category" % CID)
1029     else:
1030         print("Category %s contains" % (CID))
1031         for vid in vids:
1032             print(vid.upper())
1033
1034 ####################
1035
1036
1037 def consolidate(default_config, site_config, consolidated_config):
1038     global service
1039     try:
1040         conso = PLCConfiguration(default_config)
1041         conso.load(site_config)
1042         conso.save(consolidated_config)
1043     except Exception as inst:
1044         print("Could not consolidate, %s" % (str(inst)))
1045         return
1046     print(("Merged\n\t%s\nand\t%s\ninto\t%s" % (default_config, site_config,
1047                                                 consolidated_config)))
1048
1049
1050 def reload_service():
1051     global service
1052     os.system("set -x ; systemctl reload %s" % service)
1053
1054 ####################
1055
1056
1057 def restart_service():
1058     global service
1059     print(("==================== Stopping %s" % service))
1060     os.system("systemctl stop %s" % service)
1061     print(("==================== Starting %s" % service))
1062     os.system("systemctl start %s" % service)
1063
1064 ####################
1065
1066
1067 def prompt_variable(cdef, cread, cwrite, category, variable,
1068                     show_comments, support_next=False):
1069
1070     assert 'id' in category
1071     assert 'id' in variable
1072
1073     category_id = category['id']
1074     variable_id = variable['id']
1075
1076     while True:
1077         default_value = get_value(cdef, category_id, variable_id)
1078         variable_type = get_type(cdef, category_id, variable_id)
1079         current_value = get_current_value(
1080             cread, cwrite, category_id, variable_id)
1081         varname = get_varname(cread, category_id, variable_id)
1082
1083         if show_comments:
1084             print_name_comments(cdef, category_id, variable_id)
1085         prompt = "== %s : [%s] " % (varname, current_value)
1086         try:
1087             answer = input(prompt).strip()
1088         except EOFError:
1089             raise Exception('BailOut')
1090         except KeyboardInterrupt:
1091             print("\n")
1092             raise Exception('BailOut')
1093
1094         # no change
1095         if (answer == "") or (answer == current_value):
1096             return None
1097         elif (answer == "."):
1098             raise Exception('BailOut')
1099         elif (answer == "#"):
1100             print_name_comments(cread, category_id, variable_id)
1101         elif (answer == "?"):
1102             print(variable_usage.strip())
1103         elif (answer == "="):
1104             print(("%s defaults to %s" % (varname, default_value)))
1105         # revert to default : remove from cwrite (i.e. site-config)
1106         elif (answer == "/"):
1107             cwrite.delete(category_id, variable_id)
1108             print(("%s reverted to %s" % (varname, default_value)))
1109             return
1110         elif (answer == ">"):
1111             if support_next:
1112                 raise Exception('NextCategory')
1113             else:
1114                 print("No support for next category")
1115         else:
1116             if cdef.validate_type(variable_type, answer):
1117                 variable['value'] = answer
1118                 cwrite.set(category, variable)
1119                 return
1120             else:
1121                 print("Not a valid value")
1122
1123
1124 def prompt_variables_all(cdef, cread, cwrite, show_comments):
1125     try:
1126         for (category_id, (category, variables)) in cread.variables().items():
1127             print(("========== Category = %s" % category_id.upper()))
1128             for variable in list(variables.values()):
1129                 try:
1130                     newvar = prompt_variable(cdef, cread, cwrite, category, variable,
1131                                              show_comments, True)
1132                 except Exception as inst:
1133                     if (str(inst) == 'NextCategory'):
1134                         break
1135                     else:
1136                         raise
1137
1138     except Exception as inst:
1139         if (str(inst) == 'BailOut'):
1140             return
1141         else:
1142             raise
1143
1144
1145 def prompt_variables_category(cdef, cread, cwrite, cid, show_comments):
1146     cid = cid.lower()
1147     CID = cid.upper()
1148     try:
1149         print(("========== Category = %s" % CID))
1150         for vid in list_category(cdef, cid):
1151             (category, variable) = cdef.locate_varname(vid.upper())
1152             newvar = prompt_variable(cdef, cread, cwrite, category, variable,
1153                                      show_comments, False)
1154     except Exception as inst:
1155         if (str(inst) == 'BailOut'):
1156             return
1157         else:
1158             raise
1159
1160 ####################
1161
1162
1163 def show_variable(cdef, cread, cwrite,
1164                   category, variable, show_value, show_comments):
1165     assert 'id' in category
1166     assert 'id' in variable
1167
1168     category_id = category['id']
1169     variable_id = variable['id']
1170
1171     default_value = get_value(cdef, category_id, variable_id)
1172     current_value = get_current_value(cread, cwrite, category_id, variable_id)
1173     varname = get_varname(cread, category_id, variable_id)
1174     if show_comments:
1175         print_name_comments(cdef, category_id, variable_id)
1176     if show_value:
1177         print("%s = %s" % (varname, current_value))
1178     else:
1179         print("%s" % (varname))
1180
1181
1182 def show_variables_all(cdef, cread, cwrite, show_value, show_comments):
1183     for (category_id, (category, variables)) in cread.variables().items():
1184         print(("========== Category = %s" % category_id.upper()))
1185         for variable in list(variables.values()):
1186             show_variable(cdef, cread, cwrite,
1187                           category, variable, show_value, show_comments)
1188
1189
1190 def show_variables_category(cdef, cread, cwrite, cid, show_value, show_comments):
1191     cid = cid.lower()
1192     CID = cid.upper()
1193     print(("========== Category = %s" % CID))
1194     for vid in list_category(cdef, cid):
1195         (category, variable) = cdef.locate_varname(vid.upper())
1196         show_variable(cdef, cread, cwrite, category, variable,
1197                       show_value, show_comments)
1198
1199
1200 ####################
1201 re_mainloop_0arg = "^(?P<command>[uUwrRqlLsSeEcvVhH\?])[ \t]*$"
1202 re_mainloop_1arg = "^(?P<command>[sSeEvV])[ \t]+(?P<arg>\w+)$"
1203 matcher_mainloop_0arg = re.compile(re_mainloop_0arg)
1204 matcher_mainloop_1arg = re.compile(re_mainloop_1arg)
1205
1206
1207 def mainloop(cdef, cread, cwrite, default_config, site_config, consolidated_config):
1208     global service
1209     while True:
1210         try:
1211             answer = input(
1212                 "Enter command (u for usual changes, w to save, ? for help) ").strip()
1213         except EOFError:
1214             answer = ""
1215         except KeyboardInterrupt:
1216             print("\nBye")
1217             sys.exit()
1218
1219         if (answer == "") or (answer in "?hH"):
1220             print(mainloop_usage)
1221             continue
1222         groups_parse = matcher_mainloop_0arg.match(answer)
1223         command = None
1224         if (groups_parse):
1225             command = groups_parse.group('command')
1226             arg = None
1227         else:
1228             groups_parse = matcher_mainloop_1arg.match(answer)
1229             if (groups_parse):
1230                 command = groups_parse.group('command')
1231                 arg = groups_parse.group('arg')
1232         if not command:
1233             print(("Unknown command >%s< -- use h for help" % answer))
1234             continue
1235
1236         show_comments = command.isupper()
1237
1238         mode = 'ALL'
1239         if arg:
1240             mode = None
1241             arg = arg.lower()
1242             variables = list_category(cdef, arg)
1243             if len(variables):
1244                 # category_id as the category name
1245                 # variables as the list of variable names
1246                 mode = 'CATEGORY'
1247                 category_id = arg
1248             arg = arg.upper()
1249             (category, variable) = cdef.locate_varname(arg)
1250             if variable:
1251                 # category/variable as output by locate_varname
1252                 mode = 'VARIABLE'
1253             if not mode:
1254                 print("%s: no such category or variable" % arg)
1255                 continue
1256
1257         if command in "qQ":
1258             # todo check confirmation
1259             return
1260         elif command == "w":
1261             try:
1262                 # Confirm that various constraints are met before saving file.
1263                 validate_variables = g_configuration.get(
1264                     'validate_variables', {})
1265                 validated_variables = cwrite.verify(
1266                     cdef, cread, validate_variables)
1267                 validator = g_configuration.get('validator', noop_validator)
1268                 validator(validated_variables)
1269                 cwrite.save(site_config)
1270             except ConfigurationException as e:
1271                 print("Save failed due to a configuration exception: %s" % e)
1272                 break
1273             except:
1274                 print(traceback.print_exc())
1275                 print(("Could not save -- fix write access on %s" % site_config))
1276                 break
1277             print(("Wrote %s" % site_config))
1278             consolidate(default_config, site_config, consolidated_config)
1279             print(("You might want to type 'r' (restart %s), 'R' (reload %s) or 'q' (quit)" %
1280                    (service, service)))
1281         elif command in "uU":
1282             global usual_variables
1283             try:
1284                 for varname in usual_variables:
1285                     (category, variable) = cdef.locate_varname(varname)
1286                     if not (category is None and variable is None):
1287                         prompt_variable(cdef, cread, cwrite,
1288                                         category, variable, False)
1289             except Exception as inst:
1290                 if (str(inst) != 'BailOut'):
1291                     raise
1292         elif command == "r":
1293             restart_service()
1294         elif command == "R":
1295             reload_service()
1296         elif command == "c":
1297             print_categories(cread)
1298         elif command in "eE":
1299             if mode == 'ALL':
1300                 prompt_variables_all(cdef, cread, cwrite, show_comments)
1301             elif mode == 'CATEGORY':
1302                 prompt_variables_category(
1303                     cdef, cread, cwrite, category_id, show_comments)
1304             elif mode == 'VARIABLE':
1305                 try:
1306                     prompt_variable(cdef, cread, cwrite, category, variable,
1307                                     show_comments, False)
1308                 except Exception as inst:
1309                     if str(inst) != 'BailOut':
1310                         raise
1311         elif command in "vVsSlL":
1312             show_value = (command in "sSlL")
1313             (c1, c2, c3) = (cdef, cread, cwrite)
1314             if command in "lL":
1315                 (c1, c2, c3) = (cwrite, cwrite, cwrite)
1316             if mode == 'ALL':
1317                 show_variables_all(c1, c2, c3, show_value, show_comments)
1318             elif mode == 'CATEGORY':
1319                 show_variables_category(
1320                     c1, c2, c3, category_id, show_value, show_comments)
1321             elif mode == 'VARIABLE':
1322                 show_variable(c1, c2, c3, category, variable,
1323                               show_value, show_comments)
1324         else:
1325             print(("Unknown command >%s< -- use h for help" % answer))
1326
1327 ####################
1328 # creates directory for file if not yet existing
1329
1330
1331 def check_dir(config_file):
1332     dirname = os.path.dirname(config_file)
1333     if (not os.path.exists(dirname)):
1334         try:
1335             os.makedirs(dirname, 0o755)
1336         except OSError as e:
1337             print("Cannot create dir %s due to %s - exiting" % (dirname, e))
1338             sys.exit(1)
1339
1340         if (not os.path.exists(dirname)):
1341             print("Cannot create dir %s - exiting" % dirname)
1342             sys.exit(1)
1343         else:
1344             print("Created directory %s" % dirname)
1345
1346 ####################
1347
1348
1349 def optParserSetup(configuration):
1350     parser = OptionParser(usage=usage())
1351     parser.set_defaults(config_dir=configuration['config_dir'],
1352                         service=configuration['service'],
1353                         usual_variables=configuration['usual_variables'])
1354     parser.add_option("", "--configdir", dest="config_dir",
1355                       help="specify configuration directory")
1356     parser.add_option("", "--service", dest="service",
1357                       help="specify /etc/init.d style service name")
1358     parser.add_option("", "--usual_variable", dest="usual_variables",
1359                       action="append", help="add a usual variable")
1360     return parser
1361
1362
1363 def main(command, argv, configuration):
1364     global g_configuration
1365     g_configuration = configuration
1366
1367     parser = optParserSetup(configuration)
1368     (config, args) = parser.parse_args()
1369     if len(args) > 3:
1370         parser.error("too many arguments")
1371
1372     configuration['service'] = config.service
1373     configuration['usual_variables'] = config.usual_variables
1374     configuration['config_dir'] = config.config_dir
1375     # add in new usual_variables defined on the command line
1376     for usual_variable in config.usual_variables:
1377         if usual_variable not in configuration['usual_variables']:
1378             configuration['usual_variables'].append(usual_variable)
1379
1380     # intialize configuration
1381     init_configuration()
1382
1383     (default_config, site_config, consolidated_config) = (
1384         def_default_config, def_site_config, def_consolidated_config)
1385     if len(args) >= 1:
1386         default_config = args[0]
1387     if len(args) >= 2:
1388         site_config = args[1]
1389     if len(args) == 3:
1390         consolidated_config = args[2]
1391
1392     for c in (default_config, site_config, consolidated_config):
1393         check_dir(c)
1394
1395     try:
1396         # the default settings only - read only
1397         cdef = PLCConfiguration(default_config)
1398
1399         # in effect : default settings + local settings - read only
1400         cread = PLCConfiguration(default_config)
1401
1402     except ConfigurationException as e:
1403         print(("Error %s in default config file %s" % (e, default_config)))
1404         return 1
1405     except:
1406         print(traceback.print_exc())
1407         print(("default config files %s not found, is myplc installed ?" %
1408                default_config))
1409         return 1
1410
1411     # local settings only, will be modified & saved
1412     cwrite = PLCConfiguration()
1413
1414     try:
1415         cread.load(site_config)
1416         cwrite.load(site_config)
1417     except:
1418         cwrite = PLCConfiguration()
1419
1420     mainloop(cdef, cread, cwrite, default_config,
1421              site_config, consolidated_config)
1422     return 0
1423
1424
1425 if __name__ == '__main__':
1426     import sys
1427     if len(sys.argv) > 1 and sys.argv[1] in ['build', 'install', 'uninstall']:
1428         from distutils.core import setup
1429         setup(py_modules=["plc_config"])